diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 89b8d213..a69cfff2 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -4,59 +4,79 @@ The following people have contributed to this project to make it possible, and w ## Contributors +* [jacalata](https://github.com/jacalata) +* [jorwoods](https://github.com/jorwoods) +* [t8y8](https://github.com/t8y8) +* [bcantoni](https://github.com/bcantoni) +* [shinchris](https://github.com/shinchris) +* [vogelsgesang](https://github.com/vogelsgesang) +* [lbrendanl](https://github.com/lbrendanl) +* [LGraber](https://github.com/LGraber) +* [gaoang2148](https://github.com/gaoang2148) +* [benlower](https://github.com/benlower) +* [liu-rebecca](https://github.com/liu-rebecca) +* [guodah](https://github.com/guodah) +* [jdomingu](https://github.com/jdomingu) +* [kykrueger](https://github.com/kykrueger) +* [jz-huang](https://github.com/jz-huang) +* [opus-42](https://github.com/opus-42) +* [markm-io](https://github.com/markm-io) +* [graysonarts](https://github.com/graysonarts) +* [d45](https://github.com/d45) +* [preguraman](https://github.com/preguraman) +* [sotnich](https://github.com/sotnich) +* [mmuttreja-tableau](https://github.com/mmuttreja-tableau) +* [dependabot[bot]](https://github.com/apps/dependabot) +* [scuml](https://github.com/scuml) +* [ovinis](https://github.com/ovinis) +* [FFMMM](https://github.com/FFMMM) +* [martinbpeters](https://github.com/martinbpeters) +* [talvalin](https://github.com/talvalin) +* [dzucker-tab](https://github.com/dzucker-tab) +* [a-torres-2](https://github.com/a-torres-2) +* [nnevalainen](https://github.com/nnevalainen) +* [mbren](https://github.com/mbren) +* [wolkiewiczk](https://github.com/wolkiewiczk) +* [jacobj10](https://github.com/jacobj10) +* [hugoboos](https://github.com/hugoboos) +* [grbritz](https://github.com/grbritz) +* [fpagliar](https://github.com/fpagliar) +* [bskim45](https://github.com/bskim45) +* [baixin137](https://github.com/baixin137) +* [jessicachen79](https://github.com/jessicachen79) +* [gconklin](https://github.com/gconklin) * [geordielad](https://github.com/geordielad) -* [Hugo Stijns](https://github.com/hugoboos) -* [kovner](https://github.com/kovner) -* [Talvalin](https://github.com/Talvalin) -* [Chris Toomey](https://github.com/cmtoomey) -* [Vathsala Achar](https://github.com/VathsalaAchar) -* [Graeme Britz](https://github.com/grbritz) -* [Russ Goldin](https://github.com/tagyoureit) -* [William Lang](https://github.com/williamlang) -* [Jim Morris](https://github.com/jimbodriven) -* [BingoDinkus](https://github.com/BingoDinkus) -* [Sergey Sotnichenko](https://github.com/sotnich) -* [Bruce Zhang](https://github.com/baixin137) -* [Bumsoo Kim](https://github.com/bskim45) +* [fossabot](https://github.com/fossabot) * [daniel1608](https://github.com/daniel1608) -* [Joshua Jacob](https://github.com/jacobj10) -* [Francisco Pagliaricci](https://github.com/fpagliar) -* [Tomasz Machalski](https://github.com/toomyem) -* [Jared Dominguez](https://github.com/jdomingu) -* [Brendan Lee](https://github.com/lbrendanl) -* [Martin Dertz](https://github.com/martydertz) -* [Christian Oliff](https://github.com/coliff) -* [Albin Antony](https://github.com/user9747) -* [prae04](https://github.com/prae04) -* [Martin Peters](https://github.com/martinbpeters) -* [Sherman K](https://github.com/shrmnk) -* [Jorge Fonseca](https://github.com/JorgeFonseca) -* [Kacper Wolkiewicz](https://github.com/wolkiewiczk) -* [Dahai Guo](https://github.com/guodah) -* [Geraldine Zanolli](https://github.com/illonage) -* [Jordan Woods](https://github.com/jorwoods) -* [Reba Magier](https://github.com/rmagier1) -* [Stephen Mitchell](https://github.com/scuml) -* [absentmoose](https://github.com/absentmoose) -* [Paul Vickers](https://github.com/paulvic) -* [Madhura Selvarajan](https://github.com/maddy-at-leisure) -* [Niklas Nevalainen](https://github.com/nnevalainen) -* [Terrence Jones](https://github.com/tjones-commits) -* [John Vandenberg](https://github.com/jayvdb) -* [Lee Boynton](https://github.com/lboynton) * [annematronic](https://github.com/annematronic) - -## Core Team - -* [Chris Shin](https://github.com/shinchris) -* [Lee Graber](https://github.com/lgraber) -* [Tyler Doyle](https://github.com/t8y8) -* [Russell Hay](https://github.com/RussTheAerialist) -* [Ben Lower](https://github.com/benlower) -* [Ang Gao](https://github.com/gaoang2148) -* [Priya Reguraman](https://github.com/preguraman) -* [Jac Fitzgerald](https://github.com/jacalata) -* [Dan Zucker](https://github.com/dzucker-tab) -* [Brian Cantoni](https://github.com/bcantoni) -* [Ovini Nanayakkara](https://github.com/ovinis) -* [Manish Muttreja](https://github.com/mmuttreja-tableau) +* [rshide](https://github.com/rshide) +* [VathsalaAchar](https://github.com/VathsalaAchar) +* [TrimPeachu](https://github.com/TrimPeachu) +* [ajbosco](https://github.com/ajbosco) +* [jimbodriven](https://github.com/jimbodriven) +* [ltiffanydev](https://github.com/ltiffanydev) +* [martydertz](https://github.com/martydertz) +* [r-richmond](https://github.com/r-richmond) +* [sfarr15](https://github.com/sfarr15) +* [tagyoureit](https://github.com/tagyoureit) +* [tjones-commits](https://github.com/tjones-commits) +* [yoshichan5](https://github.com/yoshichan5) +* [wlodi83](https://github.com/wlodi83) +* [anipmehta](https://github.com/anipmehta) +* [cmtoomey](https://github.com/cmtoomey) +* [pes-magic](https://github.com/pes-magic) +* [illonage](https://github.com/illonage) +* [jayvdb](https://github.com/jayvdb) +* [jorgeFons](https://github.com/jorgeFons) +* [Kovner](https://github.com/Kovner) +* [LarsBreddemann](https://github.com/LarsBreddemann) +* [lboynton](https://github.com/lboynton) +* [maddy-at-leisure](https://github.com/maddy-at-leisure) +* [narcolino-tableau](https://github.com/narcolino-tableau) +* [PatrickfBraz](https://github.com/PatrickfBraz) +* [paulvic](https://github.com/paulvic) +* [shrmnk](https://github.com/shrmnk) +* [TableauKyle](https://github.com/TableauKyle) +* [bossenti](https://github.com/bossenti) +* [ma7tcsp](https://github.com/ma7tcsp) +* [toomyem](https://github.com/toomyem) diff --git a/README.md b/README.md index ab6a66fa..5c80f337 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,14 @@ Use the Tableau Server Client (TSC) library to increase your productivity as you * Create users and groups. * Query projects, sites, and more. -This repository contains Python source code for the library and sample files showing how to use it. As of May 2022, Python versions 3.7 and up are supported. +This repository contains Python source code for the library and sample files showing how to use it. As of September 2024, support for Python 3.7 and 3.8 will be dropped - support for older versions of Python aims to match https://devguide.python.org/versions/ To see sample code that works directly with the REST API (in Java, Python, or Postman), visit the [REST API Samples](https://github.com/tableau/rest-api-samples) repo. For more information on installing and using TSC, see the documentation: - +To contribute, see our [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide). A list of all our contributors to date is in [CONTRIBUTORS.md]. ## License [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftableau%2Fserver-client-python?ref=badge_large) diff --git a/contributing.md b/contributing.md index 6404611a..a0132919 100644 --- a/contributing.md +++ b/contributing.md @@ -10,8 +10,6 @@ Contribution can include, but are not limited to, any of the following: * Fix an Issue/Bug * Add/Fix documentation -Contributions must follow the guidelines outlined on the [Tableau Organization](http://tableau.github.io/) page, though filing an issue or requesting a feature do not require the CLA. - ## Issues and Feature Requests To submit an issue/bug report, or to request a feature, please submit a [GitHub issue](https://github.com/tableau/server-client-python/issues) to the repo. @@ -22,65 +20,6 @@ files to assist in the repro. **Be sure to scrub the files of any potentially s For a feature request, please try to describe the scenario you are trying to accomplish that requires the feature. This will help us understand the limitations that you are running into, and provide us with a use case to know if we've satisfied your request. -### Label usage on Issues - -The core team is responsible for assigning most labels to the issue. Labels -are used for prioritizing the core team's work, and use the following -definitions for labels. - -The following labels are only to be set or changed by the core team: - -* **bug** - A bug is an unintended behavior for existing functionality. It only relates to existing functionality and the behavior that is expected with that functionality. We do not use **bug** to indicate priority. -* **enhancement** - An enhancement is a new piece of functionality and is related to the fact that new code will need to be written in order to close this issue. We do not use **enhancement** to indicate priority. -* **CLARequired** - This label is used to indicate that the contribution will require that the CLA is signed before we can accept a PR. This label should not be used on Issues -* **CLANotRequired** - This label is used to indicate that the contribution does not require a CLA to be signed. This is used for minor fixes and usually around doc fixes or correcting strings. -* **help wanted** - This label on an issue indicates it's a good choice for external contributors to take on. It usually means it's an issue that can be tackled by first time contributors. - -The following labels can be used by the issue creator or anyone in the -community to help us prioritize enhancement and bug fixes that are -causing pain from our users. The short of it is, purple tags are ones that -anyone can add to an issue: - -* **Critical** - This means that you won't be able to use the library until the issues have been resolved. If an issue is already labeled as critical, but you want to show your support for it, add a +1 comment to the issue. This helps us know what issues are really impacting our users. -* **Nice To Have** - This means that the issue doesn't block your usage of the library, but would make your life easier. Like with critical, if the issue is already tagged with this, but you want to show your support, add a +1 comment to the issue. - -## Fixes, Implementations, and Documentation - -For all other things, please submit a PR that includes the fix, documentation, or new code that you are trying to contribute. More information on -creating a PR can be found in the [Development Guide](https://tableau.github.io/server-client-python/docs/dev-guide). - -If the feature is complex or has multiple solutions that could be equally appropriate approaches, it would be helpful to file an issue to discuss the -design trade-offs of each solution before implementing, to allow us to collectively arrive at the best solution, which most likely exists in the middle -somewhere. - -## Getting Started - -```shell -python -m build -pytest -``` - -### To use your locally built version -```shell -pip install . -``` - -### Debugging Tools -See what your outgoing requests look like: https://requestbin.net/ (unaffiliated link not under our control) - - -### Before Committing - -Our CI runs include a Python lint run, so you should run this locally and fix complaints before committing as this will fail your checkin. - -```shell -# this will run the formatter without making changes -black . --check - -# this will format the directory and code for you -black . +### Making Contributions -# this will run type checking -pip install mypy -mypy tableauserverclient test samples -``` +Refer to the [Developer Guide](https://tableau.github.io/server-client-python/docs/dev-guide) which explains how to make contributions to the TSC project. diff --git a/getcontributors.py b/getcontributors.py new file mode 100644 index 00000000..54ca81cb --- /dev/null +++ b/getcontributors.py @@ -0,0 +1,9 @@ +import json +import requests + + +logins = json.loads( + requests.get("https://api.github.com/repos/tableau/server-client-python/contributors?per_page=200").text +) +for login in logins: + print(f"* [{login["login"]}]({login["html_url"]})") diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 91205d81..bab2cf05 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -17,10 +17,14 @@ FlowRunItem, FileuploadItem, GroupItem, + GroupSetItem, HourlyInterval, IntervalItem, JobItem, JWTAuth, + LinkedTaskItem, + LinkedTaskStepItem, + LinkedTaskFlowRunItem, MetricItem, MonthlyInterval, PaginationItem, @@ -39,6 +43,7 @@ TaskItem, UserItem, ViewItem, + VirtualConnectionItem, WebhookItem, WeeklyInterval, WorkbookItem, @@ -79,6 +84,7 @@ "FlowRunItem", "FileuploadItem", "GroupItem", + "GroupSetItem", "HourlyInterval", "IntervalItem", "JobItem", @@ -116,4 +122,8 @@ "Pager", "Server", "Sort", + "LinkedTaskItem", + "LinkedTaskStepItem", + "LinkedTaskFlowRunItem", + "VirtualConnectionItem", ] diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py index 1a4a7dc3..63872398 100644 --- a/tableauserverclient/config.py +++ b/tableauserverclient/config.py @@ -1,13 +1,25 @@ -# TODO: check for env variables, else set default values +import os ALLOWED_FILE_EXTENSIONS = ["tds", "tdsx", "tde", "hyper", "parquet"] BYTES_PER_MB = 1024 * 1024 -# For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks -CHUNK_SIZE_MB = 5 * 10 # 5MB felt too slow, upped it to 50 - DELAY_SLEEP_SECONDS = 0.1 # The maximum size of a file that can be published in a single request is 64MB FILESIZE_LIMIT_MB = 64 + + +class Config: + # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks + @property + def CHUNK_SIZE_MB(self): + return int(os.getenv("TSC_CHUNK_SIZE_MB", 5 * 10)) # 5MB felt too slow, upped it to 50 + + # Default page size + @property + def PAGE_SIZE(self): + return int(os.getenv("TSC_PAGE_SIZE", 100)) + + +config = Config() diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 5fdf3c2c..e4131b72 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -14,6 +14,7 @@ from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.flow_run_item import FlowRunItem from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.groupset_item import GroupSetItem from tableauserverclient.models.interval_item import ( IntervalItem, DailyInterval, @@ -22,6 +23,11 @@ HourlyInterval, ) from tableauserverclient.models.job_item import JobItem, BackgroundJobItem +from tableauserverclient.models.linked_tasks_item import ( + LinkedTaskItem, + LinkedTaskStepItem, + LinkedTaskFlowRunItem, +) from tableauserverclient.models.metric_item import MetricItem from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.models.permissions_item import PermissionsRule, Permission @@ -39,6 +45,7 @@ from tableauserverclient.models.task_item import TaskItem from tableauserverclient.models.user_item import UserItem from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem from tableauserverclient.models.webhook_item import WebhookItem from tableauserverclient.models.workbook_item import WorkbookItem @@ -60,6 +67,7 @@ "FlowItem", "FlowRunItem", "GroupItem", + "GroupSetItem", "IntervalItem", "JobItem", "DailyInterval", @@ -89,6 +97,10 @@ "TaskItem", "UserItem", "ViewItem", + "VirtualConnectionItem", "WebhookItem", "WorkbookItem", + "LinkedTaskItem", + "LinkedTaskStepItem", + "LinkedTaskFlowRunItem", ] diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 29ffd270..62ff530c 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -66,12 +66,14 @@ def from_response(cls, resp, ns) -> List["ConnectionItem"]: for connection_xml in all_connection_xml: connection_item = cls() connection_item._id = connection_xml.get("id", None) - connection_item._connection_type = connection_xml.get("type", None) + connection_item._connection_type = connection_xml.get("type", connection_xml.get("dbClass", None)) connection_item.embed_password = string_to_bool(connection_xml.get("embedPassword", "")) connection_item.server_address = connection_xml.get("serverAddress", None) connection_item.server_port = connection_xml.get("serverPort", None) connection_item.username = connection_xml.get("userName", None) - connection_item._query_tagging = string_to_bool(connection_xml.get("queryTaggingEnabled", None)) + connection_item._query_tagging = ( + string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None + ) datasource_elem = connection_xml.find(".//t:datasource", namespaces=ns) if datasource_elem is not None: connection_item._datasource_id = datasource_elem.get("id", None) diff --git a/tableauserverclient/models/database_item.py b/tableauserverclient/models/database_item.py index 3d5a00a1..dfc58e1b 100644 --- a/tableauserverclient/models/database_item.py +++ b/tableauserverclient/models/database_item.py @@ -44,6 +44,12 @@ def __init__(self, name, description=None, content_permissions=None): self._tables = None # Not implemented yet + def __str__(self): + return "".format(self._id, self.name) + + def __repr__(self): + return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" + @property def dqws(self): if self._data_quality_warnings is None: diff --git a/tableauserverclient/models/flow_item.py b/tableauserverclient/models/flow_item.py index d543ad8e..edce2ec9 100644 --- a/tableauserverclient/models/flow_item.py +++ b/tableauserverclient/models/flow_item.py @@ -6,12 +6,12 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .connection_item import ConnectionItem -from .dqw_item import DQWItem -from .exceptions import UnpopulatedPropertyError -from .permissions_item import Permission -from .property_decorators import property_not_nullable -from .tag_item import TagItem +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.dqw_item import DQWItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.permissions_item import Permission +from tableauserverclient.models.property_decorators import property_not_nullable +from tableauserverclient.models.tag_item import TagItem class FlowItem(object): diff --git a/tableauserverclient/models/groupset_item.py b/tableauserverclient/models/groupset_item.py new file mode 100644 index 00000000..ffb57adf --- /dev/null +++ b/tableauserverclient/models/groupset_item.py @@ -0,0 +1,53 @@ +from typing import Dict, List, Optional +import xml.etree.ElementTree as ET + +from defusedxml.ElementTree import fromstring + +from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.reference_item import ResourceReference + + +class GroupSetItem: + tag_name: str = "groupSet" + + def __init__(self, name: Optional[str] = None) -> None: + self.name = name + self.id: Optional[str] = None + self.groups: List["GroupItem"] = [] + self.group_count: int = 0 + + def __str__(self) -> str: + name = self.name + id = self.id + return f"<{self.__class__.__qualname__}({name=}, {id=})>" + + def __repr__(self) -> str: + return self.__str__() + + @classmethod + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["GroupSetItem"]: + parsed_response = fromstring(response) + all_groupset_xml = parsed_response.findall(".//t:groupSet", namespaces=ns) + return [cls.from_xml(xml, ns) for xml in all_groupset_xml] + + @classmethod + def from_xml(cls, groupset_xml: ET.Element, ns: Dict[str, str]) -> "GroupSetItem": + def get_group(group_xml: ET.Element) -> GroupItem: + group_item = GroupItem() + group_item._id = group_xml.get("id") + group_item.name = group_xml.get("name") + return group_item + + group_set_item = cls() + group_set_item.name = groupset_xml.get("name") + group_set_item.id = groupset_xml.get("id") + group_set_item.group_count = int(count) if (count := groupset_xml.get("groupCount")) else 0 + group_set_item.groups = [ + get_group(group_xml) for group_xml in groupset_xml.findall(".//t:group", namespaces=ns) + ] + + return group_set_item + + @staticmethod + def as_reference(id_: str) -> ResourceReference: + return ResourceReference(id_, GroupSetItem.tag_name) diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 3ee1fee0..444674e1 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -246,21 +246,34 @@ def interval(self): @interval.setter def interval(self, interval_values): - # This is weird because the value could be a str or an int - # The only valid str is 'LastDay' so we check that first. If that's not it - # try to convert it to an int, if that fails because it's an incorrect string - # like 'badstring' we catch and re-raise. Otherwise we convert to int and check - # that it's in range 1-31 + # Valid monthly intervals strings can contain any of the following + # day numbers (1-31) (integer or string) + # relative day within the month (First, Second, ... Last) + # week days (Sunday, Monday, ... LastDay) + VALID_INTERVALS = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "LastDay", + "First", + "Second", + "Third", + "Fourth", + "Fifth", + "Last", + ] + for value in range(1, 32): + VALID_INTERVALS.append(str(value)) + VALID_INTERVALS.append(value) + for interval_value in interval_values: - error = "Invalid interval value for a monthly frequency: {}.".format(interval_value) - - if interval_value != "LastDay": - try: - if not (1 <= int(interval_value) <= 31): - raise ValueError(error) - except ValueError: - if interval_value != "LastDay": - raise ValueError(error) + if interval_value not in VALID_INTERVALS: + error = f"Invalid monthly interval: {interval_value}" + raise ValueError(error) self._interval = interval_values diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index 61e7a8d1..155ce668 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -4,7 +4,7 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .flow_run_item import FlowRunItem +from tableauserverclient.models.flow_run_item import FlowRunItem class JobItem(object): @@ -33,6 +33,8 @@ def __init__( datasource_id: Optional[str] = None, flow_run: Optional[FlowRunItem] = None, updated_at: Optional[datetime.datetime] = None, + workbook_name: Optional[str] = None, + datasource_name: Optional[str] = None, ): self._id = id_ self._type = job_type @@ -47,6 +49,8 @@ def __init__( self._datasource_id = datasource_id self._flow_run = flow_run self._updated_at = updated_at + self._workbook_name = workbook_name + self._datasource_name = datasource_name @property def id(self) -> str: @@ -117,6 +121,14 @@ def flow_run(self, value): def updated_at(self) -> Optional[datetime.datetime]: return self._updated_at + @property + def workbook_name(self) -> Optional[str]: + return self._workbook_name + + @property + def datasource_name(self) -> Optional[str]: + return self._datasource_name + def __str__(self): return ( "" + return f"<{self.__class__.__qualname__} {self._id} {self._type}>" def __repr__(self): return self.__str__() + " { " + ", ".join(" % s: % s" % item for item in vars(self).items()) + "}" diff --git a/tableauserverclient/models/linked_tasks_item.py b/tableauserverclient/models/linked_tasks_item.py new file mode 100644 index 00000000..ae9b6042 --- /dev/null +++ b/tableauserverclient/models/linked_tasks_item.py @@ -0,0 +1,102 @@ +import datetime as dt +from typing import List, Optional + +from defusedxml.ElementTree import fromstring + +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.schedule_item import ScheduleItem + + +class LinkedTaskItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.num_steps: Optional[int] = None + self.schedule: Optional[ScheduleItem] = None + + @classmethod + def from_response(cls, resp: bytes, namespace) -> List["LinkedTaskItem"]: + parsed_response = fromstring(resp) + return [ + cls._parse_element(x, namespace) + for x in parsed_response.findall(".//t:linkedTasks[@id]", namespaces=namespace) + ] + + @classmethod + def _parse_element(cls, xml, namespace) -> "LinkedTaskItem": + task = cls() + task.id = xml.get("id") + task.num_steps = int(xml.get("numSteps")) + task.schedule = ScheduleItem.from_element(xml, namespace)[0] + return task + + +class LinkedTaskStepItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.step_number: Optional[int] = None + self.stop_downstream_on_failure: Optional[bool] = None + self.task_details: List[LinkedTaskFlowRunItem] = [] + + @classmethod + def from_task_xml(cls, xml, namespace) -> List["LinkedTaskStepItem"]: + return [cls._parse_element(x, namespace) for x in xml.findall(".//t:linkedTaskSteps[@id]", namespace)] + + @classmethod + def _parse_element(cls, xml, namespace) -> "LinkedTaskStepItem": + step = cls() + step.id = xml.get("id") + step.step_number = int(xml.get("stepNumber")) + step.stop_downstream_on_failure = string_to_bool(xml.get("stopDownstreamTasksOnFailure")) + step.task_details = LinkedTaskFlowRunItem._parse_element(xml, namespace) + return step + + +class LinkedTaskFlowRunItem: + def __init__(self) -> None: + self.flow_run_id: Optional[str] = None + self.flow_run_priority: Optional[int] = None + self.flow_run_consecutive_failed_count: Optional[int] = None + self.flow_run_task_type: Optional[str] = None + self.flow_id: Optional[str] = None + self.flow_name: Optional[str] = None + + @classmethod + def _parse_element(cls, xml, namespace) -> List["LinkedTaskFlowRunItem"]: + all_tasks = [] + for flow_run in xml.findall(".//t:flowRun[@id]", namespace): + task = cls() + task.flow_run_id = flow_run.get("id") + task.flow_run_priority = int(flow_run.get("priority")) + task.flow_run_consecutive_failed_count = int(flow_run.get("consecutiveFailedCount")) + task.flow_run_task_type = flow_run.get("type") + flow = flow_run.find(".//t:flow[@id]", namespace) + task.flow_id = flow.get("id") + task.flow_name = flow.get("name") + all_tasks.append(task) + + return all_tasks + + +class LinkedTaskJobItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.linked_task_id: Optional[str] = None + self.status: Optional[str] = None + self.created_at: Optional[dt.datetime] = None + + @classmethod + def from_response(cls, resp: bytes, namespace) -> "LinkedTaskJobItem": + parsed_response = fromstring(resp) + job = cls() + job_xml = parsed_response.find(".//t:linkedTaskJob[@id]", namespaces=namespace) + if job_xml is None: + raise ValueError("No linked task job found in response") + job.id = job_xml.get("id") + job.linked_task_id = job_xml.get("linkedTaskId") + job.status = job_xml.get("status") + job.created_at = parse_datetime(job_xml.get("createdAt")) + return job + + +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index fecdb972..26f4ee7e 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -3,10 +3,11 @@ from defusedxml.ElementTree import fromstring -from .exceptions import UnknownGranteeTypeError, UnpopulatedPropertyError -from .group_item import GroupItem -from .reference_item import ResourceReference -from .user_item import UserItem +from tableauserverclient.models.exceptions import UnknownGranteeTypeError, UnpopulatedPropertyError +from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.groupset_item import GroupSetItem +from tableauserverclient.models.reference_item import ResourceReference +from tableauserverclient.models.user_item import UserItem from tableauserverclient.helpers.logging import logger @@ -142,6 +143,8 @@ def _parse_grantee_element(grantee_capability_xml: ET.Element, ns: Optional[Dict grantee = UserItem.as_reference(grantee_id) elif grantee_type == "group": grantee = GroupItem.as_reference(grantee_id) + elif grantee_type == "groupSet": + grantee = GroupSetItem.as_reference(grantee_id) else: raise UnknownGranteeTypeError("No support for grantee type of {}".format(grantee_type)) diff --git a/tableauserverclient/models/tableau_types.py b/tableauserverclient/models/tableau_types.py index 33fe5eb0..bac07207 100644 --- a/tableauserverclient/models/tableau_types.py +++ b/tableauserverclient/models/tableau_types.py @@ -1,11 +1,12 @@ from typing import Union -from .datasource_item import DatasourceItem -from .flow_item import FlowItem -from .project_item import ProjectItem -from .view_item import ViewItem -from .workbook_item import WorkbookItem -from .metric_item import MetricItem +from tableauserverclient.models.datasource_item import DatasourceItem +from tableauserverclient.models.flow_item import FlowItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.workbook_item import WorkbookItem +from tableauserverclient.models.metric_item import MetricItem +from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem class Resource: @@ -18,12 +19,13 @@ class Resource: Metric = "metric" Project = "project" View = "view" + VirtualConnection = "virtualConnection" Workbook = "workbook" # resource types that have permissions, can be renamed, etc # todo: refactoring: should actually define TableauItem as an interface and let all these implement it -TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem] +TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem] def plural_type(content_type: Resource) -> str: diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py new file mode 100644 index 00000000..76a3b5de --- /dev/null +++ b/tableauserverclient/models/virtual_connection_item.py @@ -0,0 +1,77 @@ +import datetime as dt +import json +from typing import Callable, Dict, Iterable, List, Optional +from xml.etree.ElementTree import Element + +from defusedxml.ElementTree import fromstring + +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.permissions_item import PermissionsRule + + +class VirtualConnectionItem: + def __init__(self, name: str) -> None: + self.name = name + self.created_at: Optional[dt.datetime] = None + self.has_extracts: Optional[bool] = None + self._id: Optional[str] = None + self.is_certified: Optional[bool] = None + self.updated_at: Optional[dt.datetime] = None + self.webpage_url: Optional[str] = None + self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None + self.project_id: Optional[str] = None + self.owner_id: Optional[str] = None + self.content: Optional[Dict[str, dict]] = None + self.certification_note: Optional[str] = None + + def __str__(self) -> str: + return f"{self.__class__.__qualname__}(name={self.name})" + + def __repr__(self) -> str: + return f"<{self!s}>" + + def _set_permissions(self, permissions): + self._permissions = permissions + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def permissions(self) -> List[PermissionsRule]: + if self._permissions is None: + error = "Workbook item must be populated with permissions first." + raise UnpopulatedPropertyError(error) + return self._permissions() + + @property + def connections(self) -> Iterable[ConnectionItem]: + if self._connections is None: + raise AttributeError("connections not populated. Call populate_connections() first.") + return self._connections() + + @classmethod + def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["VirtualConnectionItem"]: + parsed_response = fromstring(response) + return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)] + + @classmethod + def from_xml(cls, xml: Element, ns: Dict[str, str]) -> "VirtualConnectionItem": + v_conn = cls(xml.get("name", "")) + v_conn._id = xml.get("id", None) + v_conn.webpage_url = xml.get("webpageUrl", None) + v_conn.created_at = parse_datetime(xml.get("createdAt", None)) + v_conn.updated_at = parse_datetime(xml.get("updatedAt", None)) + v_conn.is_certified = string_to_bool(s) if (s := xml.get("isCertified", None)) else None + v_conn.certification_note = xml.get("certificationNote", None) + v_conn.has_extracts = string_to_bool(s) if (s := xml.get("hasExtracts", None)) else None + v_conn.project_id = p.get("id", None) if ((p := xml.find(".//t:project[@id]", ns)) is not None) else None + v_conn.owner_id = o.get("id", None) if ((o := xml.find(".//t:owner[@id]", ns)) is not None) else None + v_conn.content = json.loads(c.text or "{}") if ((c := xml.find(".//t:content", ns)) is not None) else None + return v_conn + + +def string_to_bool(s: str) -> bool: + return s.lower() in ["true", "1", "t", "y", "yes"] diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 024350aa..b05b9add 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -12,7 +12,9 @@ from tableauserverclient.server.endpoint.flows_endpoint import Flows from tableauserverclient.server.endpoint.flow_task_endpoint import FlowTasks from tableauserverclient.server.endpoint.groups_endpoint import Groups +from tableauserverclient.server.endpoint.groupsets_endpoint import GroupSets from tableauserverclient.server.endpoint.jobs_endpoint import Jobs +from tableauserverclient.server.endpoint.linked_tasks_endpoint import LinkedTasks from tableauserverclient.server.endpoint.metadata_endpoint import Metadata from tableauserverclient.server.endpoint.metrics_endpoint import Metrics from tableauserverclient.server.endpoint.projects_endpoint import Projects @@ -21,9 +23,11 @@ from tableauserverclient.server.endpoint.sites_endpoint import Sites from tableauserverclient.server.endpoint.subscriptions_endpoint import Subscriptions from tableauserverclient.server.endpoint.tables_endpoint import Tables +from tableauserverclient.server.endpoint.resource_tagger import Tags from tableauserverclient.server.endpoint.tasks_endpoint import Tasks from tableauserverclient.server.endpoint.users_endpoint import Users from tableauserverclient.server.endpoint.views_endpoint import Views +from tableauserverclient.server.endpoint.virtual_connections_endpoint import VirtualConnections from tableauserverclient.server.endpoint.webhooks_endpoint import Webhooks from tableauserverclient.server.endpoint.workbooks_endpoint import Workbooks @@ -43,7 +47,9 @@ "Flows", "FlowTasks", "Groups", + "GroupSets", "Jobs", + "LinkedTasks", "Metadata", "Metrics", "Projects", @@ -53,9 +59,11 @@ "Sites", "Subscriptions", "Tables", + "Tags", "Tasks", "Users", "Views", + "VirtualConnections", "Webhooks", "Workbooks", ] diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 0b6bac0c..468d469a 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -4,9 +4,9 @@ from defusedxml.ElementTree import fromstring -from .endpoint import Endpoint, api -from .exceptions import ServerResponseError -from ..request_factory import RequestFactory +from tableauserverclient.server.endpoint.endpoint import Endpoint, api +from tableauserverclient.server.endpoint.exceptions import ServerResponseError +from tableauserverclient.server.request_factory import RequestFactory from tableauserverclient.helpers.logging import logger diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index d1446b1f..57a5b010 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -1,8 +1,13 @@ +import io import logging -from typing import List, Optional, Tuple - -from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError +import os +from pathlib import Path +from typing import List, Optional, Tuple, Union + +from tableauserverclient.config import BYTES_PER_MB, FILESIZE_LIMIT_MB +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 @@ -16,6 +21,15 @@ update the name or owner of a custom view. """ +FilePath = Union[str, os.PathLike] +FileObject = Union[io.BufferedReader, io.BytesIO] +FileObjectR = Union[io.BufferedReader, io.BytesIO] +FileObjectW = Union[io.BufferedWriter, io.BytesIO] +PathOrFileR = Union[FilePath, FileObjectR] +PathOrFileW = Union[FilePath, FileObjectW] +io_types_r = (io.BufferedReader, io.BytesIO) +io_types_w = (io.BufferedWriter, io.BytesIO) + class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): @@ -25,6 +39,10 @@ def __init__(self, parent_srv): def baseurl(self) -> str: return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id) + @property + def expurl(self) -> str: + return f"{self.parent_srv._server_address}/api/exp/sites/{self.parent_srv.site_id}/customviews" + """ If the request has no filter parameters: Administrators will see all custom views. Other users will see only custom views that they own. @@ -102,3 +120,46 @@ def delete(self, view_id: str) -> None: url = "{0}/{1}".format(self.baseurl, view_id) self.delete_request(url) logger.info("Deleted single custom view (ID: {0})".format(view_id)) + + @api(version="3.21") + def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW: + url = f"{self.expurl}/{view_item.id}/content" + server_response = self.get_request(url) + if isinstance(file, io_types_w): + file.write(server_response.content) + return file + + with open(file, "wb") as f: + f.write(server_response.content) + + return file + + @api(version="3.21") + def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[CustomViewItem]: + url = self.expurl + if isinstance(file, io_types_r): + size = get_file_object_size(file) + elif isinstance(file, (str, Path)) and (p := Path(file)).is_file(): + size = p.stat().st_size + else: + raise ValueError("File path or file object required for publishing custom view.") + + if size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: + upload_session_id = self.parent_srv.fileuploads.upload(file) + url = f"{url}?uploadSessionId={upload_session_id}" + xml_request, content_type = RequestFactory.CustomView.publish_req_chunked(view_item) + else: + if isinstance(file, io_types_r): + file.seek(0) + contents = file.read() + if view_item.name is None: + raise MissingRequiredFieldError("Custom view item missing name.") + filename = view_item.name + elif isinstance(file, (str, Path)): + filename = Path(file).name + contents = Path(file).read_bytes() + + xml_request, content_type = RequestFactory.CustomView.publish_req(view_item, filename, contents) + + server_response = self.post_request(url, xml_request, content_type) + return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 849072a1..2f8fece0 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -1,17 +1,19 @@ import logging - -from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import api, Endpoint -from .exceptions import MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint +from typing import Union, Iterable, Set + +from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint +from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint +from tableauserverclient.server.endpoint.endpoint import api, Endpoint +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.server import RequestFactory from tableauserverclient.models import DatabaseItem, TableItem, PaginationItem, Resource from tableauserverclient.helpers.logging import logger -class Databases(Endpoint): +class Databases(Endpoint, TaggingMixin): def __init__(self, parent_srv): super(Databases, self).__init__(parent_srv) @@ -123,3 +125,15 @@ def add_dqw(self, item, warning): @api(version="3.5") def delete_dqw(self, item): self._data_quality_warnings.clear(item) + + @api(version="3.9") + def add_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> Set[str]: + return super().add_tags(item, tags) + + @api(version="3.9") + def delete_tags(self, item: Union[DatabaseItem, str], tags: Iterable[str]) -> None: + super().delete_tags(item, tags) + + @api(version="3.9") + def update_tags(self, item: DatabaseItem) -> None: + raise NotImplementedError("Update tags is not supported for databases.") diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 316f078a..7f3a4707 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -6,9 +6,10 @@ from contextlib import closing from pathlib import Path -from typing import List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING, Union +from typing import Iterable, List, Mapping, Optional, Sequence, Set, Tuple, TYPE_CHECKING, Union from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.server.query import QuerySet if TYPE_CHECKING: from tableauserverclient.server import Server @@ -19,9 +20,9 @@ from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint -from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin -from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB +from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, config from tableauserverclient.filesys_helpers import ( make_download_path, get_file_type, @@ -54,10 +55,9 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Datasources(QuerysetEndpoint[DatasourceItem]): +class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: super(Datasources, self).__init__(parent_srv) - self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) self._data_quality_warnings = _DataQualityWarningEndpoint(self.parent_srv, "datasource") @@ -126,7 +126,7 @@ def download( datasource_id: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - ) -> str: + ) -> PathOrFileW: return self.download_revision( datasource_id, None, @@ -149,7 +149,7 @@ def update(self, datasource_item: DatasourceItem) -> DatasourceItem: ) raise MissingRequiredFieldError(error) - self._resource_tagger.update_tags(self.baseurl, datasource_item) + self.update_tags(datasource_item) # Update the datasource itself url = "{0}/{1}".format(self.baseurl, datasource_item.id) @@ -272,7 +272,7 @@ def publish( if file_size >= FILESIZE_LIMIT_MB * BYTES_PER_MB: logger.info( "Publishing {} to server with chunking method (datasource over {}MB, chunk size {}MB)".format( - filename, FILESIZE_LIMIT_MB, CHUNK_SIZE_MB + filename, FILESIZE_LIMIT_MB, config.CHUNK_SIZE_MB ) ) upload_session_id = self.parent_srv.fileuploads.upload(file) @@ -405,7 +405,7 @@ def _get_datasource_revisions( def download_revision( self, datasource_id: str, - revision_number: str, + revision_number: Optional[str], filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> PathOrFileW: @@ -459,3 +459,99 @@ def schedule_extract_refresh( self, schedule_id: str, item: DatasourceItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, datasource=item) + + @api(version="1.0") + def add_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) + + @api(version="1.0") + def delete_tags(self, item: Union[DatasourceItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + @api(version="1.0") + def update_tags(self, item: DatasourceItem) -> None: + return super().update_tags(item) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[DatasourceItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + authentication_type=... + authentication_type__in=... + connected_workbook_type=... + connected_workbook_type__gt=... + connected_workbook_type__gte=... + connected_workbook_type__lt=... + connected_workbook_type__lte=... + connection_to=... + connection_to__in=... + connection_type=... + connection_type__in=... + content_url=... + content_url__in=... + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + database_name=... + database_name__in=... + database_user_name=... + database_user_name__in=... + description=... + description__in=... + favorites_total=... + favorites_total__gt=... + favorites_total__gte=... + favorites_total__lt=... + favorites_total__lte=... + has_alert=... + has_embedded_password=... + has_extracts=... + is_certified=... + is_connectable=... + is_default_port=... + is_hierarchical=... + is_published=... + name=... + name__in=... + owner_domain=... + owner_domain__in=... + owner_email=... + owner_name=... + owner_name__in=... + project_name=... + project_name__in=... + server_name=... + server_name__in=... + server_port=... + size=... + size__gt=... + size__gte=... + size__lt=... + size__lte=... + table_name=... + table_name__in=... + tags=... + tags__in=... + type=... + updated_at=... + updated_at__gt=... + updated_at__gte=... + updated_at__lt=... + updated_at__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 6b29e736..be0602df 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,30 +1,41 @@ +from typing_extensions import Concatenate, ParamSpec from tableauserverclient import datetime_helpers as datetime import abc from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Generic, List, Optional, TYPE_CHECKING, Tuple, TypeVar, Union +from typing import ( + Any, + Callable, + Dict, + Generic, + List, + Optional, + TYPE_CHECKING, + Tuple, + TypeVar, + Union, +) from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions -from .exceptions import ( +from tableauserverclient.server.endpoint.exceptions import ( ServerResponseError, InternalServerError, NonXMLResponseError, NotSignedInError, ) -from ..exceptions import EndpointUnavailableError +from tableauserverclient.server.exceptions import EndpointUnavailableError from tableauserverclient.server.query import QuerySet from tableauserverclient import helpers, get_versions from tableauserverclient.helpers.logging import logger -from tableauserverclient.config import DELAY_SLEEP_SECONDS if TYPE_CHECKING: - from ..server import Server + from tableauserverclient.server.server import Server from requests import Response @@ -38,7 +49,7 @@ USER_AGENT_HEADER = "User-Agent" -class Endpoint(object): +class Endpoint: def __init__(self, parent_srv: "Server"): self.parent_srv = parent_srv @@ -133,7 +144,9 @@ def _make_request( loggable_response = self.log_response_safely(server_response) logger.debug("Server response from {0}".format(url)) - # logger.debug("\n\t{1}".format(loggable_response)) + # uncomment the following to log full responses in debug mode + # BE CAREFUL WHEN SHARING THESE RESULTS - MAY CONTAIN YOUR SENSITIVE DATA + # logger.debug(loggable_response) if content_type == "application/xml": self.parent_srv._namespace.detect(server_response.content) @@ -232,7 +245,12 @@ def patch_request(self, url, xml_request, content_type=XML_CONTENT_TYPE, paramet ) -def api(version): +E = TypeVar("E", bound="Endpoint") +P = ParamSpec("P") +R = TypeVar("R") + + +def api(version: str) -> Callable[[Callable[Concatenate[E, P], R]], Callable[Concatenate[E, P], R]]: """Annotate the minimum supported version for an endpoint. Checks the version on the server object and compares normalized versions. @@ -251,9 +269,9 @@ def api(version): >>> ... """ - def _decorator(func): + def _decorator(func: Callable[Concatenate[E, P], R]) -> Callable[Concatenate[E, P], R]: @wraps(func) - def wrapper(self, *args, **kwargs): + def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: self.parent_srv.assert_at_least_version(version, self.__class__.__name__) return func(self, *args, **kwargs) @@ -262,7 +280,7 @@ def wrapper(self, *args, **kwargs): return _decorator -def parameter_added_in(**params): +def parameter_added_in(**params: str) -> Callable[[Callable[Concatenate[E, P], R]], Callable[Concatenate[E, P], R]]: """Annotate minimum versions for new parameters or request options on an endpoint. The api decorator documents when an endpoint was added, this decorator annotates @@ -285,9 +303,9 @@ def parameter_added_in(**params): >>> ... """ - def _decorator(func): + def _decorator(func: Callable[Concatenate[E, P], R]) -> Callable[Concatenate[E, P], R]: @wraps(func) - def wrapper(self, *args, **kwargs): + def wrapper(self: E, *args: P.args, **kwargs: P.kwargs) -> R: import warnings server_ver = Version(self.parent_srv.version or "0.0") @@ -335,5 +353,5 @@ def paginate(self, **kwargs) -> QuerySet[T]: return queryset @abc.abstractmethod - def get(self, request_options: RequestOptions) -> Tuple[List[T], PaginationItem]: + def get(self, request_options: Optional[RequestOptions] = None) -> Tuple[List[T], PaginationItem]: raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/fileuploads_endpoint.py b/tableauserverclient/server/endpoint/fileuploads_endpoint.py index a0e29e50..0d30797c 100644 --- a/tableauserverclient/server/endpoint/fileuploads_endpoint.py +++ b/tableauserverclient/server/endpoint/fileuploads_endpoint.py @@ -2,7 +2,7 @@ from tableauserverclient import datetime_helpers as datetime from tableauserverclient.helpers.logging import logger -from tableauserverclient.config import BYTES_PER_MB, CHUNK_SIZE_MB +from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.models import FileuploadItem from tableauserverclient.server import RequestFactory @@ -41,7 +41,7 @@ def _read_chunks(self, file): try: while True: - chunked_content = file_content.read(CHUNK_SIZE_MB * BYTES_PER_MB) + chunked_content = file_content.read(config.CHUNK_SIZE_MB * BYTES_PER_MB) if not chunked_content: break yield chunked_content diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index ea45ce80..c339a064 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -1,16 +1,17 @@ import logging from typing import List, Optional, Tuple, TYPE_CHECKING -from .endpoint import QuerysetEndpoint, api -from .exceptions import FlowRunFailedException, FlowRunCancelledException +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import FlowRunFailedException, FlowRunCancelledException from tableauserverclient.models import FlowRunItem, PaginationItem from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger +from tableauserverclient.server.query import QuerySet if TYPE_CHECKING: - from ..server import Server - from ..request_options import RequestOptions + from tableauserverclient.server.server import Server + from tableauserverclient.server.request_options import RequestOptions class FlowRuns(QuerysetEndpoint[FlowRunItem]): @@ -78,3 +79,42 @@ def wait_for_job(self, flow_run_id: str, *, timeout: Optional[int] = None) -> Fl raise FlowRunCancelledException(flow_run) else: raise AssertionError("Unexpected status in flow_run", flow_run) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowRunItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + complete_at=... + complete_at__gt=... + complete_at__gte=... + complete_at__lt=... + complete_at__lte=... + flow_id=... + flow_id__in=... + progress=... + progress__gt=... + progress__gte=... + progress__lt=... + progress__lte=... + started_at=... + started_at__gt=... + started_at__gte=... + started_at__lt=... + started_at__lte=... + user_id=... + user_id__in=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 2997e945..53d072f5 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -9,11 +9,11 @@ from tableauserverclient.helpers.headers import fix_filename -from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import QuerysetEndpoint, api -from .exceptions import InternalServerError, MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint -from .resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger, TaggingMixin from tableauserverclient.models import FlowItem, PaginationItem, ConnectionItem, JobItem from tableauserverclient.server import RequestFactory from tableauserverclient.filesys_helpers import ( @@ -22,6 +22,7 @@ get_file_type, get_file_object_size, ) +from tableauserverclient.server.query import QuerySet io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) @@ -50,7 +51,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Flows(QuerysetEndpoint[FlowItem]): +class Flows(QuerysetEndpoint[FlowItem], TaggingMixin[FlowItem]): def __init__(self, parent_srv): super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) @@ -295,3 +296,39 @@ def schedule_flow_run( self, schedule_id: str, item: FlowItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, flow=item) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[FlowItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + name=... + name__in=... + owner_name=... + project_id=... + project_name=... + project_name__in=... + updated=... + updated__gt=... + updated__gte=... + updated__lt=... + updated__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 2ee9fe0a..8acf3169 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -1,17 +1,19 @@ import logging -from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import GroupItem, UserItem, PaginationItem, JobItem -from ..pager import Pager +from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger -from typing import List, Optional, TYPE_CHECKING, Tuple, Union +from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union + +from tableauserverclient.server.query import QuerySet if TYPE_CHECKING: - from ..request_options import RequestOptions + from tableauserverclient.server.request_options import RequestOptions class Groups(QuerysetEndpoint[GroupItem]): @@ -19,9 +21,9 @@ class Groups(QuerysetEndpoint[GroupItem]): def baseurl(self) -> str: return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) - # Gets all groups @api(version="2.0") def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[GroupItem], PaginationItem]: + """Gets all groups""" logger.info("Querying all groups on site") url = self.baseurl server_response = self.get_request(url, req_options) @@ -29,9 +31,9 @@ def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[Grou all_group_items = GroupItem.from_response(server_response.content, self.parent_srv.namespace) return all_group_items, pagination_item - # Gets all users in a given group @api(version="2.0") - def populate_users(self, group_item, req_options: Optional["RequestOptions"] = None) -> None: + def populate_users(self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None) -> None: + """Gets all users in a given group""" if not group_item.id: error = "Group item missing ID. Group must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -47,7 +49,7 @@ def user_pager(): group_item._set_users(user_pager) def _get_users_for_group( - self, group_item, req_options: Optional["RequestOptions"] = None + self, group_item: GroupItem, req_options: Optional["RequestOptions"] = None ) -> Tuple[List[UserItem], PaginationItem]: url = "{0}/{1}/users".format(self.baseurl, group_item.id) server_response = self.get_request(url, req_options) @@ -56,9 +58,9 @@ def _get_users_for_group( logger.info("Populated users for group (ID: {0})".format(group_item.id)) return user_item, pagination_item - # Deletes 1 group by id @api(version="2.0") def delete(self, group_id: str) -> None: + """Deletes 1 group by id""" if not group_id: error = "Group ID undefined." raise ValueError(error) @@ -87,17 +89,17 @@ def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem else: return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] - # Create a 'local' Tableau group @api(version="2.0") def create(self, group_item: GroupItem) -> GroupItem: + """Create a 'local' Tableau group""" url = self.baseurl create_req = RequestFactory.Group.create_local_req(group_item) server_response = self.post_request(url, create_req) return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] - # Create a group based on Active Directory @api(version="2.0") def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[GroupItem, JobItem]: + """Create a group based on Active Directory""" asJobparameter = "?asJob=true" if asJob else "" url = self.baseurl + asJobparameter create_req = RequestFactory.Group.create_ad_req(group_item) @@ -107,9 +109,9 @@ def create_AD_group(self, group_item: GroupItem, asJob: bool = False) -> Union[G else: return GroupItem.from_response(server_response.content, self.parent_srv.namespace)[0] - # Removes 1 user from 1 group @api(version="2.0") def remove_user(self, group_item: GroupItem, user_id: str) -> None: + """Removes 1 user from 1 group""" if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -120,9 +122,22 @@ def remove_user(self, group_item: GroupItem, user_id: str) -> None: self.delete_request(url) logger.info("Removed user (id: {0}) from group (ID: {1})".format(user_id, group_item.id)) - # Adds 1 user to 1 group + @api(version="3.21") + def remove_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> None: + """Removes multiple users from 1 group""" + group_id = group_item.id if hasattr(group_item, "id") else group_item + if not isinstance(group_id, str): + raise ValueError(f"Invalid group provided: {group_item}") + + url = f"{self.baseurl}/{group_id}/users/remove" + add_req = RequestFactory.Group.remove_users_req(users) + _ = self.put_request(url, add_req) + logger.info("Removed users to group (ID: {0})".format(group_item.id)) + return None + @api(version="2.0") def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: + """Adds 1 user to 1 group""" if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) @@ -135,3 +150,56 @@ def add_user(self, group_item: GroupItem, user_id: str) -> UserItem: user = UserItem.from_response(server_response.content, self.parent_srv.namespace).pop() logger.info("Added user (id: {0}) to group (ID: {1})".format(user_id, group_item.id)) return user + + @api(version="3.21") + def add_users(self, group_item: GroupItem, users: Iterable[Union[str, UserItem]]) -> List[UserItem]: + """Adds multiple users to 1 group""" + group_id = group_item.id if hasattr(group_item, "id") else group_item + if not isinstance(group_id, str): + raise ValueError(f"Invalid group provided: {group_item}") + + url = f"{self.baseurl}/{group_id}/users" + add_req = RequestFactory.Group.add_users_req(users) + server_response = self.post_request(url, add_req) + users = UserItem.from_response(server_response.content, self.parent_srv.namespace) + logger.info("Added users to group (ID: {0})".format(group_item.id)) + return users + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + domain_name=... + domain_name__in=... + domain_nickname=... + domain_nickname__in=... + is_external_user_enabled=... + is_local=... + luid=... + luid__in=... + minimum_site_role=... + minimum_site_role__in=... + name__cieq=... + name=... + name__in=... + name__like=... + user_count=... + user_count__gt=... + user_count__gte=... + user_count__lt=... + user_count__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/endpoint/groupsets_endpoint.py b/tableauserverclient/server/endpoint/groupsets_endpoint.py new file mode 100644 index 00000000..06e7cc62 --- /dev/null +++ b/tableauserverclient/server/endpoint/groupsets_endpoint.py @@ -0,0 +1,127 @@ +from typing import List, Literal, Optional, Tuple, TYPE_CHECKING, Union + +from tableauserverclient.helpers.logging import logger +from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.groupset_item import GroupSetItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint +from tableauserverclient.server.query import QuerySet +from tableauserverclient.server.request_options import RequestOptions +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.endpoint.endpoint import api + +if TYPE_CHECKING: + from tableauserverclient.server import Server + + +class GroupSets(QuerysetEndpoint[GroupSetItem]): + def __init__(self, parent_srv: "Server") -> None: + super().__init__(parent_srv) + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/groupsets" + + @api(version="3.22") + def get( + self, + request_options: Optional[RequestOptions] = None, + result_level: Optional[Literal["members", "local"]] = None, + ) -> Tuple[List[GroupSetItem], PaginationItem]: + logger.info("Querying all group sets on site") + url = self.baseurl + if result_level: + url += f"?resultlevel={result_level}" + server_response = self.get_request(url, request_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_set_items, pagination_item + + @api(version="3.22") + def get_by_id(self, groupset_id: str) -> GroupSetItem: + logger.info(f"Querying group set (ID: {groupset_id})") + url = f"{self.baseurl}/{groupset_id}" + server_response = self.get_request(url) + all_group_set_items = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_set_items[0] + + @api(version="3.22") + def create(self, groupset_item: GroupSetItem) -> GroupSetItem: + logger.info(f"Creating group set (name: {groupset_item.name})") + url = self.baseurl + request = RequestFactory.GroupSet.create_request(groupset_item) + server_response = self.post_request(url, request) + created_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return created_groupset[0] + + @api(version="3.22") + def add_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) -> None: + group_id = group.id if isinstance(group, GroupItem) else group + logger.info(f"Adding group (ID: {group_id}) to group set (ID: {groupset_item.id})") + url = f"{self.baseurl}/{groupset_item.id}/groups/{group_id}" + _ = self.put_request(url) + return None + + @api(version="3.22") + def remove_group(self, groupset_item: GroupSetItem, group: Union[GroupItem, str]) -> None: + group_id = group.id if isinstance(group, GroupItem) else group + logger.info(f"Removing group (ID: {group_id}) from group set (ID: {groupset_item.id})") + url = f"{self.baseurl}/{groupset_item.id}/groups/{group_id}" + _ = self.delete_request(url) + return None + + @api(version="3.22") + def delete(self, groupset: Union[GroupSetItem, str]) -> None: + groupset_id = groupset.id if isinstance(groupset, GroupSetItem) else groupset + logger.info(f"Deleting group set (ID: {groupset_id})") + url = f"{self.baseurl}/{groupset_id}" + _ = self.delete_request(url) + return None + + @api(version="3.22") + def update(self, groupset: GroupSetItem) -> GroupSetItem: + logger.info(f"Updating group set (ID: {groupset.id})") + url = f"{self.baseurl}/{groupset.id}" + request = RequestFactory.GroupSet.update_request(groupset) + server_response = self.put_request(url, request) + updated_groupset = GroupSetItem.from_response(server_response.content, self.parent_srv.namespace) + return updated_groupset[0] + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[GroupSetItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + domain_name=... + domain_name__in=... + domain_nickname=... + domain_nickname__in=... + is_external_user_enabled=... + is_local=... + luid=... + luid__in=... + minimum_site_role=... + minimum_site_role__in=... + name__cieq=... + name=... + name__in=... + name__like=... + user_count=... + user_count__gt=... + user_count__gte=... + user_count__lt=... + user_count__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index 74770e22..ae8cf263 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -1,9 +1,12 @@ import logging +from typing_extensions import Self, overload + -from .endpoint import QuerysetEndpoint, api -from .exceptions import JobCancelledException, JobFailedException from tableauserverclient.models import JobItem, BackgroundJobItem, PaginationItem -from ..request_options import RequestOptionsBase +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import JobCancelledException, JobFailedException +from tableauserverclient.server.query import QuerySet +from tableauserverclient.server.request_options import RequestOptionsBase from tableauserverclient.exponential_backoff import ExponentialBackoffTimer from tableauserverclient.helpers.logging import logger @@ -11,15 +14,25 @@ from typing import List, Optional, Tuple, Union -class Jobs(QuerysetEndpoint[JobItem]): +class Jobs(QuerysetEndpoint[BackgroundJobItem]): @property def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) + @overload # type: ignore[override] + def get(self: Self, job_id: str, req_options: Optional[RequestOptionsBase] = None) -> JobItem: # type: ignore[override] + ... + + @overload # type: ignore[override] + def get(self: Self, job_id: RequestOptionsBase, req_options: None) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + ... + + @overload # type: ignore[override] + def get(self: Self, job_id: None, req_options: Optional[RequestOptionsBase]) -> Tuple[List[BackgroundJobItem], PaginationItem]: # type: ignore[override] + ... + @api(version="2.6") - def get( - self, job_id: Optional[str] = None, req_options: Optional[RequestOptionsBase] = None - ) -> Tuple[List[BackgroundJobItem], PaginationItem]: + def get(self, job_id=None, req_options=None): # Backwards Compatibility fix until we rev the major version if job_id is not None and isinstance(job_id, str): import warnings @@ -74,3 +87,57 @@ def wait_for_job(self, job_id: Union[str, JobItem], *, timeout: Optional[float] raise JobCancelledException(job) else: raise AssertionError("Unexpected finish_code in job", job) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[BackgroundJobItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + args__has=... + completed_at=... + completed_at__gt=... + completed_at__gte=... + completed_at__lt=... + completed_at__lte=... + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + job_type=... + job_type__in=... + notes__has=... + priority=... + priority__gt=... + priority__gte=... + priority__lt=... + priority__lte=... + progress=... + progress__gt=... + progress__gte=... + progress__lt=... + progress__lte=... + started_at=... + started_at__gt=... + started_at__gte=... + started_at__lt=... + started_at__lte=... + status=... + subtitle=... + subtitle__has=... + title=... + title__has=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/endpoint/linked_tasks_endpoint.py b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py new file mode 100644 index 00000000..37413050 --- /dev/null +++ b/tableauserverclient/server/endpoint/linked_tasks_endpoint.py @@ -0,0 +1,45 @@ +from typing import List, Optional, Tuple, Union + +from tableauserverclient.helpers.logging import logger +from tableauserverclient.models.linked_tasks_item import LinkedTaskItem, LinkedTaskJobItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.request_options import RequestOptions + + +class LinkedTasks(QuerysetEndpoint[LinkedTaskItem]): + def __init__(self, parent_srv): + super().__init__(parent_srv) + self._parent_srv = parent_srv + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/tasks/linked" + + @api(version="3.15") + def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[LinkedTaskItem], PaginationItem]: + logger.info("Querying all linked tasks on site") + url = self.baseurl + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_items, pagination_item + + @api(version="3.15") + def get_by_id(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskItem: + task_id = getattr(linked_task, "id", linked_task) + logger.info("Querying all linked tasks on site") + url = f"{self.baseurl}/{task_id}" + server_response = self.get_request(url) + all_group_items = LinkedTaskItem.from_response(server_response.content, self.parent_srv.namespace) + return all_group_items[0] + + @api(version="3.15") + def run_now(self, linked_task: Union[LinkedTaskItem, str]) -> LinkedTaskJobItem: + task_id = getattr(linked_task, "id", linked_task) + logger.info(f"Running linked task {task_id} now") + url = f"{self.baseurl}/{task_id}/runNow" + empty_req = RequestFactory.Empty.empty_req() + server_response = self.post_request(url, empty_req) + return LinkedTaskJobItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 259f53b1..565817e3 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -9,6 +9,8 @@ from typing import List, Optional, Tuple, TYPE_CHECKING +from tableauserverclient.server.query import QuerySet + if TYPE_CHECKING: from tableauserverclient.server.server import Server from tableauserverclient.server.request_options import RequestOptions @@ -154,3 +156,44 @@ def delete_flow_default_permissions(self, item, rule): @api(version="3.4") def delete_lens_default_permissions(self, item, rule): self._default_permissions.delete_default_permission(item, rule, Resource.Lens) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ProjectItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + name=... + name__in=... + owner_domain=... + owner_domain__in=... + owner_email=... + owner_email__in=... + owner_name=... + owner_name__in=... + parent_project_id=... + parent_project_id__in=... + top_level_project=... + updated_at=... + updated_at__gt=... + updated_at__gte=... + updated_at__lt=... + updated_at__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/endpoint/resource_tagger.py b/tableauserverclient/server/endpoint/resource_tagger.py index 8177bd73..1894e3b8 100644 --- a/tableauserverclient/server/endpoint/resource_tagger.py +++ b/tableauserverclient/server/endpoint/resource_tagger.py @@ -1,14 +1,25 @@ +import abc import copy +from typing import Generic, Iterable, Optional, Protocol, Set, TypeVar, Union, TYPE_CHECKING, runtime_checkable import urllib.parse -from .endpoint import Endpoint -from .exceptions import ServerResponseError -from ..exceptions import EndpointUnavailableError +from tableauserverclient.server.endpoint.endpoint import Endpoint, api +from tableauserverclient.server.endpoint.exceptions import ServerResponseError +from tableauserverclient.server.exceptions import EndpointUnavailableError from tableauserverclient.server import RequestFactory from tableauserverclient.models import TagItem from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.models.column_item import ColumnItem + from tableauserverclient.models.database_item import DatabaseItem + from tableauserverclient.models.datasource_item import DatasourceItem + from tableauserverclient.models.flow_item import FlowItem + from tableauserverclient.models.table_item import TableItem + from tableauserverclient.models.workbook_item import WorkbookItem + from tableauserverclient.server.server import Server + class _ResourceTagger(Endpoint): # Add new tags to resource @@ -49,3 +60,124 @@ def update_tags(self, baseurl, resource_item): resource_item.tags = self._add_tags(baseurl, resource_item.id, add_set) resource_item._initial_tags = copy.copy(resource_item.tags) logger.info("Updated tags to {0}".format(resource_item.tags)) + + +class Response(Protocol): + content: bytes + + +@runtime_checkable +class Taggable(Protocol): + tags: Set[str] + _initial_tags: Set[str] + + @property + def id(self) -> Optional[str]: + pass + + +T = TypeVar("T") + + +class TaggingMixin(abc.ABC, Generic[T]): + parent_srv: "Server" + + @property + @abc.abstractmethod + def baseurl(self) -> str: + pass + + @abc.abstractmethod + def put_request(self, url, request) -> Response: + pass + + @abc.abstractmethod + def delete_request(self, url) -> None: + pass + + def add_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> Set[str]: + item_id = getattr(item, "id", item) + + if not isinstance(item_id, str): + raise ValueError("ID not found.") + + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + url = f"{self.baseurl}/{item_id}/tags" + add_req = RequestFactory.Tag.add_req(tag_set) + server_response = self.put_request(url, add_req) + return TagItem.from_response(server_response.content, self.parent_srv.namespace) + + def delete_tags(self, item: Union[T, str], tags: Union[Iterable[str], str]) -> None: + item_id = getattr(item, "id", item) + + if not isinstance(item_id, str): + raise ValueError("ID not found.") + + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + for tag in tag_set: + encoded_tag_name = urllib.parse.quote(tag) + url = f"{self.baseurl}/{item_id}/tags/{encoded_tag_name}" + self.delete_request(url) + + def update_tags(self, item: T) -> None: + if (initial_tags := getattr(item, "_initial_tags", None)) is None: + raise ValueError(f"{item} does not have initial tags.") + if (tags := getattr(item, "tags", None)) is None: + raise ValueError(f"{item} does not have tags.") + if tags == initial_tags: + return + + add_set = tags - initial_tags + remove_set = initial_tags - tags + self.delete_tags(item, remove_set) + if add_set: + tags = self.add_tags(item, add_set) + setattr(item, "tags", tags) + + setattr(item, "_initial_tags", copy.copy(tags)) + logger.info(f"Updated tags to {tags}") + + +content = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] + + +class Tags(Endpoint): + def __init__(self, parent_srv: "Server"): + super().__init__(parent_srv) + + @property + def baseurl(self): + return f"{self.parent_srv.baseurl}/tags" + + @api(version="3.9") + def batch_add(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + url = f"{self.baseurl}:batchCreate" + batch_create_req = RequestFactory.Tag.batch_create(tag_set, content) + server_response = self.put_request(url, batch_create_req) + return TagItem.from_response(server_response.content, self.parent_srv.namespace) + + @api(version="3.9") + def batch_delete(self, tags: Union[Iterable[str], str], content: content) -> Set[str]: + if isinstance(tags, str): + tag_set = set([tags]) + else: + tag_set = set(tags) + + url = f"{self.baseurl}:batchDelete" + # The batch delete XML is the same as the batch create XML. + batch_delete_req = RequestFactory.Tag.batch_create(tag_set, content) + server_response = self.put_request(url, batch_delete_req) + return TagItem.from_response(server_response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index b4c5181e..36ef78c0 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -1,17 +1,19 @@ import logging +from typing import Iterable, Set, Union -from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import api, Endpoint -from .exceptions import MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint +from tableauserverclient.server.endpoint.endpoint import api, Endpoint +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.server import RequestFactory from tableauserverclient.models import TableItem, ColumnItem, PaginationItem -from ..pager import Pager +from tableauserverclient.server.pager import Pager from tableauserverclient.helpers.logging import logger -class Tables(Endpoint): +class Tables(Endpoint, TaggingMixin[TableItem]): def __init__(self, parent_srv): super(Tables, self).__init__(parent_srv) @@ -124,3 +126,14 @@ def add_dqw(self, item, warning): @api(version="3.5") def delete_dqw(self, item): self._data_quality_warnings.clear(item) + + @api(version="3.9") + def add_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) + + @api(version="3.9") + def delete_tags(self, item: Union[TableItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + def update_tags(self, item: TableItem) -> None: # type: ignore + raise NotImplementedError("Update tags is not implemented for TableItem") diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index a84ca739..c4b6418b 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -2,6 +2,8 @@ import logging from typing import List, Optional, Tuple +from tableauserverclient.server.query import QuerySet + from .endpoint import QuerysetEndpoint, api from .exceptions import MissingRequiredFieldError, ServerResponseError from tableauserverclient.server import RequestFactory, RequestOptions @@ -166,3 +168,40 @@ def _get_groups_for_user( group_item = GroupItem.from_response(server_response.content, self.parent_srv.namespace) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) return group_item, pagination_item + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[UserItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + domain_name=... + domain_name__in=... + friendly_name=... + friendly_name__in=... + is_local=... + last_login=... + last_login__gt=... + last_login__gte=... + last_login__lt=... + last_login__lte=... + luid=... + luid__in=... + name__cieq=... + name=... + name__in=... + site_role=... + site_role__in=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index f98eb1cd..f2ccf658 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -1,18 +1,20 @@ import logging from contextlib import closing -from .endpoint import QuerysetEndpoint, api -from .exceptions import MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint -from .resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin +from tableauserverclient.server.query import QuerySet + from tableauserverclient.models import ViewItem, PaginationItem from tableauserverclient.helpers.logging import logger -from typing import Iterator, List, Optional, Tuple, TYPE_CHECKING +from typing import Iterable, Iterator, List, Optional, Set, Tuple, TYPE_CHECKING, Union if TYPE_CHECKING: - from ..request_options import ( + from tableauserverclient.server.request_options import ( RequestOptions, CSVRequestOptions, PDFRequestOptions, @@ -21,10 +23,9 @@ ) -class Views(QuerysetEndpoint[ViewItem]): +class Views(QuerysetEndpoint[ViewItem], TaggingMixin[ViewItem]): def __init__(self, parent_srv): super(Views, self).__init__(parent_srv) - self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) # Used because populate_preview_image functionaliy requires workbook endpoint @@ -169,7 +170,91 @@ def update(self, view_item: ViewItem) -> ViewItem: error = "View item missing ID. View must be retrieved from server first." raise MissingRequiredFieldError(error) - self._resource_tagger.update_tags(self.baseurl, view_item) + self.update_tags(view_item) # Returning view item to stay consistent with datasource/view update functions return view_item + + @api(version="1.0") + def add_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) + + @api(version="1.0") + def delete_tags(self, item: Union[ViewItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + @api(version="1.0") + def update_tags(self, item: ViewItem) -> None: + return super().update_tags(item) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[ViewItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + caption=... + caption__in=... + content_url=... + content_url__in=... + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + favorites_total=... + favorites_total__gt=... + favorites_total__gte=... + favorites_total__lt=... + favorites_total__lte=... + fields=... + fields__in=... + hits_total=... + hits_total__gt=... + hits_total__gte=... + hits_total__lt=... + hits_total__lte=... + name=... + name__in=... + owner_domain=... + owner_domain__in=... + owner_email=... + owner_email__in=... + owner_name=... + project_name=... + project_name__in=... + sheet_number=... + sheet_number__gt=... + sheet_number__gte=... + sheet_number__lt=... + sheet_number__lte=... + sheet_type=... + sheet_type__in=... + tags=... + tags__in=... + title=... + title__in=... + updated_at=... + updated_at__gt=... + updated_at__gte=... + updated_at__lt=... + updated_at__lte=... + view_url_name=... + view_url_name__in=... + workbook_description=... + workbook_description__in=... + workbook_name=... + workbook_name__in=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py new file mode 100644 index 00000000..f71db00c --- /dev/null +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -0,0 +1,173 @@ +from functools import partial +import json +from pathlib import Path +from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union + +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.models.revision_item import RevisionItem +from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.request_options import RequestOptions +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin +from tableauserverclient.server.pager import Pager + +if TYPE_CHECKING: + from tableauserverclient.server import Server + + +class VirtualConnections(QuerysetEndpoint[VirtualConnectionItem], TaggingMixin): + def __init__(self, parent_srv: "Server") -> None: + super().__init__(parent_srv) + self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections" + + @api(version="3.18") + def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[VirtualConnectionItem], PaginationItem]: + server_response = self.get_request(self.baseurl, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + return virtual_connections, pagination_item + + @api(version="3.18") + def populate_connections(self, virtual_connection: VirtualConnectionItem) -> VirtualConnectionItem: + def _connection_fetcher(): + return Pager(partial(self._get_virtual_database_connections, virtual_connection)) + + virtual_connection._connections = _connection_fetcher + return virtual_connection + + def _get_virtual_database_connections( + self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None + ) -> Tuple[List[ConnectionItem], PaginationItem]: + server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options) + connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + + return connections, pagination_item + + @api(version="3.18") + def update_connection_db_connection( + self, virtual_connection: Union[str, VirtualConnectionItem], connection: ConnectionItem + ) -> ConnectionItem: + vconn_id = getattr(virtual_connection, "id", virtual_connection) + url = f"{self.baseurl}/{vconn_id}/connections/{connection.id}/modify" + xml_request = RequestFactory.VirtualConnection.update_db_connection(connection) + server_response = self.put_request(url, xml_request) + return ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.23") + def get_by_id(self, virtual_connection: Union[str, VirtualConnectionItem]) -> VirtualConnectionItem: + vconn_id = getattr(virtual_connection, "id", virtual_connection) + url = f"{self.baseurl}/{vconn_id}" + server_response = self.get_request(url) + return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.23") + def download(self, virtual_connection: Union[str, VirtualConnectionItem]) -> str: + v_conn = self.get_by_id(virtual_connection) + return json.dumps(v_conn.content) + + @api(version="3.23") + def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnectionItem: + url = f"{self.baseurl}/{virtual_connection.id}" + xml_request = RequestFactory.VirtualConnection.update(virtual_connection) + server_response = self.put_request(url, xml_request) + return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.23") + def get_revisions( + self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None + ) -> Tuple[List[RevisionItem], PaginationItem]: + server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection) + return revisions, pagination_item + + @api(version="3.23") + def download_revision(self, virtual_connection: VirtualConnectionItem, revision_number: int) -> str: + url = f"{self.baseurl}/{virtual_connection.id}/revisions/{revision_number}" + server_response = self.get_request(url) + virtual_connection = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + return json.dumps(virtual_connection.content) + + @api(version="3.23") + def delete(self, virtual_connection: Union[VirtualConnectionItem, str]) -> None: + vconn_id = getattr(virtual_connection, "id", virtual_connection) + self.delete_request(f"{self.baseurl}/{vconn_id}") + + @api(version="3.23") + def publish( + self, + virtual_connection: VirtualConnectionItem, + virtual_connection_content: str, + mode: str = "CreateNew", + publish_as_draft: bool = False, + ) -> VirtualConnectionItem: + """ + Publish a virtual connection to the server. + + For the virtual_connection object, name, project_id, and owner_id are + required. + + The virtual_connection_content can be a json string or a file path to a + json file. + + The mode can be "CreateNew" or "Overwrite". If mode is + "Overwrite" and the virtual connection already exists, it will be + overwritten. + + If publish_as_draft is True, the virtual connection will be published + as a draft, and the id of the draft will be on the response object. + """ + try: + json.loads(virtual_connection_content) + except json.JSONDecodeError: + file = Path(virtual_connection_content) + if not file.exists(): + raise RuntimeError(f"{virtual_connection_content} is not valid json nor an existing file path") + content = file.read_text() + else: + content = virtual_connection_content + + if mode not in ["CreateNew", "Overwrite"]: + raise ValueError(f"Invalid mode: {mode}") + overwrite = mode == "Overwrite" + + url = f"{self.baseurl}?overwrite={str(overwrite).lower()}&publishAsDraft={str(publish_as_draft).lower()}" + xml_request = RequestFactory.VirtualConnection.publish(virtual_connection, content) + server_response = self.post_request(url, xml_request) + return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.22") + def populate_permissions(self, item: VirtualConnectionItem) -> None: + self._permissions.populate(item) + + @api(version="3.22") + def add_permissions(self, resource, rules): + return self._permissions.update(resource, rules) + + @api(version="3.22") + def delete_permission(self, item, capability_item): + return self._permissions.delete(item, capability_item) + + @api(version="3.23") + def add_tags( + self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] + ) -> Set[str]: + return super().add_tags(virtual_connection, tags) + + @api(version="3.23") + def delete_tags( + self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str] + ) -> None: + return super().delete_tags(virtual_connection, tags) + + @api(version="3.23") + def update_tags(self, virtual_connection: VirtualConnectionItem) -> None: + raise NotImplementedError("Update tags is not implemented for Virtual Connections") diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 30f8ce03..da6eda3d 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -7,11 +7,12 @@ from pathlib import Path from tableauserverclient.helpers.headers import fix_filename +from tableauserverclient.server.query import QuerySet -from .endpoint import QuerysetEndpoint, api, parameter_added_in -from .exceptions import InternalServerError, MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint -from .resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in +from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin from tableauserverclient.filesys_helpers import ( to_filename, @@ -24,9 +25,11 @@ from tableauserverclient.server import RequestFactory from typing import ( + Iterable, List, Optional, Sequence, + Set, Tuple, TYPE_CHECKING, Union, @@ -35,8 +38,8 @@ if TYPE_CHECKING: from tableauserverclient.server import Server from tableauserverclient.server.request_options import RequestOptions - from tableauserverclient.models import DatasourceItem, ConnectionCredentials - from .schedules_endpoint import AddResponse + from tableauserverclient.models import DatasourceItem + from tableauserverclient.server.endpoint.schedules_endpoint import AddResponse io_types_r = (io.BytesIO, io.BufferedReader) io_types_w = (io.BytesIO, io.BufferedWriter) @@ -56,10 +59,9 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Workbooks(QuerysetEndpoint[WorkbookItem]): +class Workbooks(QuerysetEndpoint[WorkbookItem], TaggingMixin[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) - self._resource_tagger = _ResourceTagger(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) return None @@ -147,7 +149,7 @@ def update( error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) - self._resource_tagger.update_tags(self.baseurl, workbook_item) + self.update_tags(workbook_item) # Update the workbook itself url = "{0}/{1}".format(self.baseurl, workbook_item.id) @@ -182,7 +184,7 @@ def download( workbook_id: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - ) -> str: + ) -> PathOrFileW: return self.download_revision( workbook_id, None, @@ -498,3 +500,82 @@ def schedule_extract_refresh( self, schedule_id: str, item: WorkbookItem ) -> List["AddResponse"]: # actually should return a task return self.parent_srv.schedules.add_to_schedule(schedule_id, workbook=item) + + @api(version="1.0") + def add_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> Set[str]: + return super().add_tags(item, tags) + + @api(version="1.0") + def delete_tags(self, item: Union[WorkbookItem, str], tags: Union[Iterable[str], str]) -> None: + return super().delete_tags(item, tags) + + @api(version="1.0") + def update_tags(self, item: WorkbookItem) -> None: + return super().update_tags(item) + + def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySet[WorkbookItem]: + """ + Queries the Tableau Server for items using the specified filters. Page + size can be specified to limit the number of items returned in a single + request. If not specified, the default page size is 100. Page size can + be an integer between 1 and 1000. + + No positional arguments are allowed. All filters must be specified as + keyword arguments. If you use the equality operator, you can specify it + through =. If you want to use a different operator, + you can specify it through __=. Field + names can either be in snake_case or camelCase. + + This endpoint supports the following fields and operators: + + + created_at=... + created_at__gt=... + created_at__gte=... + created_at__lt=... + created_at__lte=... + content_url=... + content_url__in=... + display_tabs=... + favorites_total=... + favorites_total__gt=... + favorites_total__gte=... + favorites_total__lt=... + favorites_total__lte=... + has_alerts=... + has_extracts=... + name=... + name__in=... + owner_domain=... + owner_domain__in=... + owner_email=... + owner_email__in=... + owner_name=... + owner_name__in=... + project_name=... + project_name__in=... + sheet_count=... + sheet_count__gt=... + sheet_count__gte=... + sheet_count__lt=... + sheet_count__lte=... + size=... + size__gt=... + size__gte=... + size__lt=... + size__lte=... + subscriptions_total=... + subscriptions_total__gt=... + subscriptions_total__gte=... + subscriptions_total__lt=... + subscriptions_total__lte=... + tags=... + tags__in=... + updated_at=... + updated_at__gt=... + updated_at__gte=... + updated_at__lt=... + updated_at__lte=... + """ + + return super().filter(*invalid, page_size=page_size, **kwargs) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index fede5601..ca9d8387 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,24 +1,23 @@ import copy from functools import partial -from typing import Generic, Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable +from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions T = TypeVar("T") -ReturnType = Tuple[List[T], PaginationItem] @runtime_checkable -class Endpoint(Protocol): - def get(self, req_options: Optional[RequestOptions], **kwargs) -> ReturnType: +class Endpoint(Protocol[T]): + def get(self, req_options: Optional[RequestOptions]) -> Tuple[List[T], PaginationItem]: ... @runtime_checkable -class CallableEndpoint(Protocol): - def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> ReturnType: +class CallableEndpoint(Protocol[T]): + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> Tuple[List[T], PaginationItem]: ... @@ -33,7 +32,7 @@ class Pager(Iterable[T]): def __init__( self, - endpoint: Union[CallableEndpoint, Endpoint], + endpoint: Union[CallableEndpoint[T], Endpoint[T]], request_opts: Optional[RequestOptions] = None, **kwargs, ) -> None: diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 19513926..bbca612e 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,6 +1,7 @@ from collections.abc import Sized from itertools import count from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from tableauserverclient.config import config from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions @@ -35,7 +36,7 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model - self.request_options = RequestOptions(pagesize=page_size or 100) + self.request_options = RequestOptions(pagesize=page_size or config.PAGE_SIZE) self._result_cache: List[T] = [] self._pagination_item = PaginationItem() diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 87438ecd..96fa1468 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1,8 +1,11 @@ import xml.etree.ElementTree as ET -from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar, TYPE_CHECKING, Union + +from typing_extensions import ParamSpec from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata +from typing_extensions import Concatenate from tableauserverclient.models import * @@ -23,8 +26,12 @@ def _add_multipart(parts: Dict) -> Tuple[Any, str]: return xml_request, content_type -def _tsrequest_wrapped(func): - def wrapper(self, *args, **kwargs) -> bytes: +T = TypeVar("T") +P = ParamSpec("P") + + +def _tsrequest_wrapped(func: Callable[Concatenate[T, ET.Element, P], Any]) -> Callable[Concatenate[T, P], bytes]: + def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> bytes: xml_request = ET.Element("tsRequest") func(self, xml_request, *args, **kwargs) return ET.tostring(xml_request) @@ -387,6 +394,28 @@ def add_user_req(self, user_id: str) -> bytes: user_element.attrib["id"] = user_id return ET.tostring(xml_request) + @_tsrequest_wrapped + def add_users_req(self, xml_request: ET.Element, users: Iterable[Union[str, UserItem]]) -> bytes: + users_element = ET.SubElement(xml_request, "users") + for user in users: + user_element = ET.SubElement(users_element, "user") + if not (user_id := user.id if isinstance(user, UserItem) else user): + raise ValueError("User ID must be populated") + user_element.attrib["id"] = user_id + + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def remove_users_req(self, xml_request: ET.Element, users: Iterable[Union[str, UserItem]]) -> bytes: + users_element = ET.SubElement(xml_request, "users") + for user in users: + user_element = ET.SubElement(users_element, "user") + if not (user_id := user.id if isinstance(user, UserItem) else user): + raise ValueError("User ID must be populated") + user_element.attrib["id"] = user_id + + return ET.tostring(xml_request) + def create_local_req(self, group_item: GroupItem) -> bytes: xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") @@ -839,6 +868,9 @@ def update_req(self, table_item): return ET.tostring(xml_request) +content_types = Iterable[Union["ColumnItem", "DatabaseItem", "DatasourceItem", "FlowItem", "TableItem", "WorkbookItem"]] + + class TagRequest(object): def add_req(self, tag_set): xml_request = ET.Element("tsRequest") @@ -848,6 +880,22 @@ def add_req(self, tag_set): tag_element.attrib["label"] = tag return ET.tostring(xml_request) + @_tsrequest_wrapped + def batch_create(self, element: ET.Element, tags: Set[str], content: content_types) -> bytes: + tag_batch = ET.SubElement(element, "tagBatch") + tags_element = ET.SubElement(tag_batch, "tags") + for tag in tags: + tag_element = ET.SubElement(tags_element, "tag") + tag_element.attrib["label"] = tag + contents_element = ET.SubElement(tag_batch, "contents") + for item in content: + content_element = ET.SubElement(contents_element, "content") + if item.id is None: + raise ValueError(f"Item {item} must have an ID to be tagged.") + content_element.attrib["id"] = item.id + + return ET.tostring(element) + class UserRequest(object): def update_req(self, user_item: UserItem, password: Optional[str]) -> bytes: @@ -1014,14 +1062,17 @@ def publish_req_chunked( return _add_multipart(parts) @_tsrequest_wrapped - def embedded_extract_req(self, xml_request, include_all=True, datasources=None): + def embedded_extract_req( + self, xml_request: ET.Element, include_all: bool = True, datasources: Optional[Iterable[DatasourceItem]] = None + ) -> None: list_element = ET.SubElement(xml_request, "datasources") if include_all: list_element.attrib["includeAll"] = "true" elif datasources: for datasource_item in datasources: datasource_element = ET.SubElement(list_element, "datasource") - datasource_element.attrib["id"] = datasource_item.id + if (id_ := datasource_item.id) is not None: + datasource_element.attrib["id"] = id_ class Connection(object): @@ -1049,7 +1100,7 @@ def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") class TaskRequest(object): @_tsrequest_wrapped - def run_req(self, xml_request, task_item): + def run_req(self, xml_request: ET.Element, task_item: Any) -> None: # Send an empty tsRequest pass @@ -1186,7 +1237,7 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt class EmptyRequest(object): @_tsrequest_wrapped - def empty_req(self, xml_request): + def empty_req(self, xml_request: ET.Element) -> None: pass @@ -1245,6 +1296,124 @@ def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem): if custom_view_item.name is not None: updating_element.attrib["name"] = custom_view_item.name + @_tsrequest_wrapped + def _publish_xml(self, xml_request: ET.Element, custom_view_item: CustomViewItem) -> bytes: + custom_view_element = ET.SubElement(xml_request, "customView") + if (name := custom_view_item.name) is not None: + custom_view_element.attrib["name"] = name + else: + raise ValueError(f"Custom View Item missing name: {custom_view_item}") + if (shared := custom_view_item.shared) is not None: + custom_view_element.attrib["shared"] = str(shared).lower() + else: + raise ValueError(f"Custom View Item missing shared: {custom_view_item}") + if (owner := custom_view_item.owner) is not None: + owner_element = ET.SubElement(custom_view_element, "owner") + if (owner_id := owner.id) is not None: + owner_element.attrib["id"] = owner_id + else: + raise ValueError(f"Custom View Item owner missing id: {owner}") + else: + raise ValueError(f"Custom View Item missing owner: {custom_view_item}") + if (workbook := custom_view_item.workbook) is not None: + workbook_element = ET.SubElement(custom_view_element, "workbook") + if (workbook_id := workbook.id) is not None: + workbook_element.attrib["id"] = workbook_id + else: + raise ValueError(f"Custom View Item workbook missing id: {workbook}") + else: + raise ValueError(f"Custom View Item missing workbook: {custom_view_item}") + + return ET.tostring(xml_request) + + def publish_req_chunked(self, custom_view_item: CustomViewItem): + xml_request = self._publish_xml(custom_view_item) + parts = {"request_payload": ("", xml_request, "text/xml")} + return _add_multipart(parts) + + def publish_req(self, custom_view_item: CustomViewItem, filename: str, file_contents: bytes): + xml_request = self._publish_xml(custom_view_item) + parts = { + "request_payload": ("", xml_request, "text/xml"), + "tableau_customview": (filename, file_contents, "application/octet-stream"), + } + return _add_multipart(parts) + + +class GroupSetRequest: + @_tsrequest_wrapped + def create_request(self, xml_request: ET.Element, group_set_item: "GroupSetItem") -> bytes: + group_set_element = ET.SubElement(xml_request, "groupSet") + if group_set_item.name is not None: + group_set_element.attrib["name"] = group_set_item.name + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def update_request(self, xml_request: ET.Element, group_set_item: "GroupSetItem") -> bytes: + group_set_element = ET.SubElement(xml_request, "groupSet") + if group_set_item.name is not None: + group_set_element.attrib["name"] = group_set_item.name + return ET.tostring(xml_request) + + +class VirtualConnectionRequest: + @_tsrequest_wrapped + def update_db_connection(self, xml_request: ET.Element, connection_item: ConnectionItem) -> bytes: + connection_element = ET.SubElement(xml_request, "connection") + if connection_item.server_address is not None: + connection_element.attrib["serverAddress"] = connection_item.server_address + if connection_item.server_port is not None: + connection_element.attrib["serverPort"] = str(connection_item.server_port) + if connection_item.username is not None: + connection_element.attrib["userName"] = connection_item.username + if connection_item.password is not None: + connection_element.attrib["password"] = connection_item.password + + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def update(self, xml_request: ET.Element, virtual_connection: VirtualConnectionItem) -> bytes: + vc_element = ET.SubElement(xml_request, "virtualConnection") + if virtual_connection.name is not None: + vc_element.attrib["name"] = virtual_connection.name + if virtual_connection.is_certified is not None: + vc_element.attrib["isCertified"] = str(virtual_connection.is_certified).lower() + if virtual_connection.certification_note is not None: + vc_element.attrib["certificationNote"] = virtual_connection.certification_note + if virtual_connection.project_id is not None: + project_element = ET.SubElement(vc_element, "project") + project_element.attrib["id"] = virtual_connection.project_id + if virtual_connection.owner_id is not None: + owner_element = ET.SubElement(vc_element, "owner") + owner_element.attrib["id"] = virtual_connection.owner_id + + return ET.tostring(xml_request) + + @_tsrequest_wrapped + def publish(self, xml_request: ET.Element, virtual_connection: VirtualConnectionItem, content: str) -> bytes: + vc_element = ET.SubElement(xml_request, "virtualConnection") + if virtual_connection.name is not None: + vc_element.attrib["name"] = virtual_connection.name + else: + raise ValueError("Virtual Connection must have a name.") + if virtual_connection.project_id is not None: + project_element = ET.SubElement(vc_element, "project") + project_element.attrib["id"] = virtual_connection.project_id + else: + raise ValueError("Virtual Connection must have a project id.") + if virtual_connection.owner_id is not None: + owner_element = ET.SubElement(vc_element, "owner") + owner_element.attrib["id"] = virtual_connection.owner_id + else: + raise ValueError("Virtual Connection must have an owner id.") + if content is not None: + content_element = ET.SubElement(vc_element, "content") + content_element.text = content + else: + raise ValueError("Virtual Connection must have content.") + + return ET.tostring(xml_request) + class RequestFactory(object): Auth = AuthRequest() @@ -1261,6 +1430,7 @@ class RequestFactory(object): Flow = FlowRequest() FlowTask = FlowTaskRequest() Group = GroupRequest() + GroupSet = GroupSetRequest() Metric = MetricRequest() Permission = PermissionRequest() Project = ProjectRequest() @@ -1271,5 +1441,6 @@ class RequestFactory(object): Tag = TagRequest() Task = TaskRequest() User = UserRequest() + VirtualConnection = VirtualConnectionRequest() Workbook = WorkbookRequest() Webhook = WebhookRequest() diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 5cc06bf9..ddb45834 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -2,6 +2,7 @@ from typing_extensions import Self +from tableauserverclient.config import config from tableauserverclient.models.property_decorators import property_is_int import logging @@ -38,6 +39,7 @@ class Operator: LessThanOrEqual = "lte" In = "in" Has = "has" + CaseInsensitiveEquals = "cieq" class Field: Args = "args" @@ -115,9 +117,9 @@ class Direction: Desc = "desc" Asc = "asc" - def __init__(self, pagenumber=1, pagesize=100): + def __init__(self, pagenumber=1, pagesize=None): self.pagenumber = pagenumber - self.pagesize = pagesize + self.pagesize = pagesize or config.PAGE_SIZE self.sort = set() self.filter = set() diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 10b1a53a..e563a713 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -33,6 +33,10 @@ Metrics, Endpoint, CustomViews, + LinkedTasks, + GroupSets, + Tags, + VirtualConnections, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -99,6 +103,10 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.flow_runs = FlowRuns(self) self.metrics = Metrics(self) self.custom_views = CustomViews(self) + self.linked_tasks = LinkedTasks(self) + self.group_sets = GroupSets(self) + self.tags = Tags(self) + self.virtual_connections = VirtualConnections(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/assets/custom_view_download.json b/test/assets/custom_view_download.json new file mode 100644 index 00000000..1ba2d74b --- /dev/null +++ b/test/assets/custom_view_download.json @@ -0,0 +1,47 @@ +[ + { + "isSourceView": true, + "viewName": "Overview", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nT3ZlcnZpZXcnIHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGFjdGl2ZSBpZD0nMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjaic-CiAgICAgIDxjb2x1bW4gZGF0YXR5cGU9J3N0cmluZycgbmFtZT0nWzpNZWFzdXJlIE5hbWVzXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJz4KICAgICAgICA8YWxpYXNlcz4KICAgICAgICAgIDxhbGlhcyBrZXk9JyZxdW90O1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW2N0ZDpDdXN0b21lciBOYW1lOnFrXSZxdW90OycgdmFsdWU9J0NvdW50IG9mIEN1c3RvbWVycycgLz4KICAgICAgICA8L2FsaWFzZXM-CiAgICAgIDwvY29sdW1uPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW25vbmU6Q2F0ZWdvcnk6bmtdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE1PTlRIKE9yZGVyIERhdGUpLFByb2R1Y3QgQ2F0ZWdvcnkpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxTZWdtZW50KV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1NlZ21lbnRdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxDYXRlZ29yeSxNT05USChPcmRlciBEYXRlKSknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoT3JkZXIgUHJvZml0YWJsZT8sQ2F0ZWdvcnksTU9OVEgoT3JkZXIgRGF0ZSkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl85MDYwMTIyMTA0OTQ3NDcxXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYXRlZ29yeV0nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxDYXRlZ29yeSxNT05USChPcmRlciBEYXRlKSknIGRhdGF0eXBlPSd0dXBsZScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxDYXRlZ29yeSxNT05USChPcmRlciBEYXRlKSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl85MDYwMTIyMTA0OTQ3NDcxXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1t0bW46T3JkZXIgRGF0ZTpva10nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbU2VnbWVudF0nIC8-CiAgICAgICAgPC9ncm91cGZpbHRlcj4KICAgICAgPC9ncm91cD4KICAgICAgPGNvbHVtbiBjYXB0aW9uPSdBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoT3JkZXIgUHJvZml0YWJsZT8sTU9OVEgoT3JkZXIgRGF0ZSksU2VnbWVudCldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKFBvc3RhbCBDb2RlLFN0YXRlL1Byb3ZpbmNlKScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSldJyBuYW1lLXN0eWxlPSd1bnF1YWxpZmllZCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluayc-CiAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdjcm9zc2pvaW4nPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW25vbmU6UG9zdGFsIENvZGU6bmtdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1N0YXRlL1Byb3ZpbmNlXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoUG9zdGFsIENvZGUsU3RhdGUvUHJvdmluY2UpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoUG9zdGFsIENvZGUsU3RhdGUvUHJvdmluY2UpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChTdGF0ZS9Qcm92aW5jZSknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoU3RhdGUvUHJvdmluY2UpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tTdGF0ZS9Qcm92aW5jZV0nIC8-CiAgICAgICAgPC9ncm91cGZpbHRlcj4KICAgICAgPC9ncm91cD4KICAgICAgPGNvbHVtbiBjYXB0aW9uPSdBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKV0nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluaycgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDYWxjdWxhdGlvbl85MDYwMTIyMTA0OTQ3NDcxXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6Q2FsY3VsYXRpb25fOTA2MDEyMjEwNDk0NzQ3MTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6cWtdJyBwaXZvdD0na2V5JyB0eXBlPSdxdWFudGl0YXRpdmUnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbUmVnaW9uXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6UmVnaW9uOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDYWxjdWxhdGlvbl85OTIxMTAzMTQ0MTAzNzQzXScgZGVyaXZhdGlvbj0nVXNlcicgbmFtZT0nW3VzcjpDYWxjdWxhdGlvbl85OTIxMTAzMTQ0MTAzNzQzOnFrXScgcGl2b3Q9J2tleScgdHlwZT0ncXVhbnRpdGF0aXZlJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdUb3RhbCBTYWxlcyc-CiAgICA8ZmlsdGVyIGNsYXNzPSdjYXRlZ29yaWNhbCcgY29sdW1uPSdbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKV0nPgogICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J21lbWJlcicgbGV2ZWw9J1tTdGF0ZS9Qcm92aW5jZV0nIG1lbWJlcj0nJnF1b3Q7VGV4YXMmcXVvdDsnIHVzZXI6dWktYWN0aW9uLWZpbHRlcj0nW0FjdGlvbjFdJyB1c2VyOnVpLWRvbWFpbj0nZGF0YWJhc2UnIHVzZXI6dWktZW51bWVyYXRpb249J2luY2x1c2l2ZScgdXNlcjp1aS1tYXJrZXI9J2VudW1lcmF0ZScgLz4KICAgIDwvZmlsdGVyPgogICAgPGZpbHRlciBjbGFzcz0nY2F0ZWdvcmljYWwnIGNvbHVtbj0nW2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bbm9uZTpSZWdpb246bmtdJyBmaWx0ZXItZ3JvdXA9JzE0Jz4KICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdtZW1iZXInIGxldmVsPSdbbm9uZTpSZWdpb246bmtdJyBtZW1iZXI9JyZxdW90O0NlbnRyYWwmcXVvdDsnIHVzZXI6dWktZG9tYWluPSdkYXRhYmFzZScgdXNlcjp1aS1lbnVtZXJhdGlvbj0naW5jbHVzaXZlJyB1c2VyOnVpLW1hcmtlcj0nZW51bWVyYXRlJyAvPgogICAgPC9maWx0ZXI-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J1NhbGUgTWFwJz4KICAgIDxmaWx0ZXIgY2xhc3M9J2NhdGVnb3JpY2FsJyBjb2x1bW49J1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW25vbmU6UmVnaW9uOm5rXScgZmlsdGVyLWdyb3VwPScxNCc-CiAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbWVtYmVyJyBsZXZlbD0nW25vbmU6UmVnaW9uOm5rXScgbWVtYmVyPScmcXVvdDtDZW50cmFsJnF1b3Q7JyB1c2VyOnVpLWRvbWFpbj0nZGF0YWJhc2UnIHVzZXI6dWktZW51bWVyYXRpb249J2luY2x1c2l2ZScgdXNlcjp1aS1tYXJrZXI9J2VudW1lcmF0ZScgLz4KICAgIDwvZmlsdGVyPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdTYWxlcyBieSBTZWdtZW50Jz4KICAgIDxmaWx0ZXIgY2xhc3M9J2NhdGVnb3JpY2FsJyBjb2x1bW49J1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW0FjdGlvbiAoU3RhdGUvUHJvdmluY2UpXSc-CiAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbWVtYmVyJyBsZXZlbD0nW1N0YXRlL1Byb3ZpbmNlXScgbWVtYmVyPScmcXVvdDtUZXhhcyZxdW90OycgdXNlcjp1aS1hY3Rpb24tZmlsdGVyPSdbQWN0aW9uMV0nIHVzZXI6dWktZG9tYWluPSdkYXRhYmFzZScgdXNlcjp1aS1lbnVtZXJhdGlvbj0naW5jbHVzaXZlJyB1c2VyOnVpLW1hcmtlcj0nZW51bWVyYXRlJyAvPgogICAgPC9maWx0ZXI-CiAgICA8ZmlsdGVyIGNsYXNzPSdjYXRlZ29yaWNhbCcgY29sdW1uPSdbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltub25lOlJlZ2lvbjpua10nIGZpbHRlci1ncm91cD0nMTQnPgogICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J21lbWJlcicgbGV2ZWw9J1tub25lOlJlZ2lvbjpua10nIG1lbWJlcj0nJnF1b3Q7Q2VudHJhbCZxdW90OycgdXNlcjp1aS1kb21haW49J2RhdGFiYXNlJyB1c2VyOnVpLWVudW1lcmF0aW9uPSdpbmNsdXNpdmUnIHVzZXI6dWktbWFya2VyPSdlbnVtZXJhdGUnIC8-CiAgICA8L2ZpbHRlcj4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0PgogIDx3b3Jrc2hlZXQgbmFtZT0nU2FsZXMgYnkgUHJvZHVjdCc-CiAgICA8ZmlsdGVyIGNsYXNzPSdjYXRlZ29yaWNhbCcgY29sdW1uPSdbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltBY3Rpb24gKFN0YXRlL1Byb3ZpbmNlKV0nPgogICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J21lbWJlcicgbGV2ZWw9J1tTdGF0ZS9Qcm92aW5jZV0nIG1lbWJlcj0nJnF1b3Q7VGV4YXMmcXVvdDsnIHVzZXI6dWktYWN0aW9uLWZpbHRlcj0nW0FjdGlvbjFdJyB1c2VyOnVpLWRvbWFpbj0nZGF0YWJhc2UnIHVzZXI6dWktZW51bWVyYXRpb249J2luY2x1c2l2ZScgdXNlcjp1aS1tYXJrZXI9J2VudW1lcmF0ZScgLz4KICAgIDwvZmlsdGVyPgogICAgPGZpbHRlciBjbGFzcz0nY2F0ZWdvcmljYWwnIGNvbHVtbj0nW2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bbm9uZTpSZWdpb246bmtdJyBmaWx0ZXItZ3JvdXA9JzE0Jz4KICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdtZW1iZXInIGxldmVsPSdbbm9uZTpSZWdpb246bmtdJyBtZW1iZXI9JyZxdW90O0NlbnRyYWwmcXVvdDsnIHVzZXI6dWktZG9tYWluPSdkYXRhYmFzZScgdXNlcjp1aS1lbnVtZXJhdGlvbj0naW5jbHVzaXZlJyB1c2VyOnVpLW1hcmtlcj0nZW51bWVyYXRlJyAvPgogICAgPC9maWx0ZXI-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d2luZG93cz4KICAgIDx3aW5kb3cgY2xhc3M9J3dvcmtzaGVldCcgbmFtZT0nU2FsZSBNYXAnPgogICAgICA8c2VsZWN0aW9uLWNvbGxlY3Rpb24-CiAgICAgICAgPHR1cGxlLXNlbGVjdGlvbj4KICAgICAgICAgIDx0dXBsZS1yZWZlcmVuY2U-CiAgICAgICAgICAgIDx0dXBsZS1kZXNjcmlwdG9yPgogICAgICAgICAgICAgIDxwYW5lLWRlc2NyaXB0b3I-CiAgICAgICAgICAgICAgICA8eC1maWVsZHM-CiAgICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltMb25naXR1ZGUgKGdlbmVyYXRlZCldPC9maWVsZD4KICAgICAgICAgICAgICAgIDwveC1maWVsZHM-CiAgICAgICAgICAgICAgICA8eS1maWVsZHM-CiAgICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltMYXRpdHVkZSAoZ2VuZXJhdGVkKV08L2ZpZWxkPgogICAgICAgICAgICAgICAgPC95LWZpZWxkcz4KICAgICAgICAgICAgICA8L3BhbmUtZGVzY3JpcHRvcj4KICAgICAgICAgICAgICA8Y29sdW1ucz4KICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltub25lOkNvdW50cnkvUmVnaW9uOm5rXTwvZmllbGQ-CiAgICAgICAgICAgICAgICA8ZmllbGQ-W2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bbm9uZTpTdGF0ZS9Qcm92aW5jZTpua108L2ZpZWxkPgogICAgICAgICAgICAgICAgPGZpZWxkPltmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW0dlb21ldHJ5IChnZW5lcmF0ZWQpXTwvZmllbGQ-CiAgICAgICAgICAgICAgICA8ZmllbGQ-W2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bTGF0aXR1ZGUgKGdlbmVyYXRlZCldPC9maWVsZD4KICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltMb25naXR1ZGUgKGdlbmVyYXRlZCldPC9maWVsZD4KICAgICAgICAgICAgICAgIDxmaWVsZD5bZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLlt1c3I6Q2FsY3VsYXRpb25fOTkyMTEwMzE0NDEwMzc0Mzpxa108L2ZpZWxkPgogICAgICAgICAgICAgIDwvY29sdW1ucz4KICAgICAgICAgICAgPC90dXBsZS1kZXNjcmlwdG9yPgogICAgICAgICAgICA8dHVwbGU-CiAgICAgICAgICAgICAgPHZhbHVlPiZxdW90O1VuaXRlZCBTdGF0ZXMmcXVvdDs8L3ZhbHVlPgogICAgICAgICAgICAgIDx2YWx1ZT4mcXVvdDtUZXhhcyZxdW90OzwvdmFsdWU-CiAgICAgICAgICAgICAgPHZhbHVlPiZxdW90O01VTFRJUE9MWUdPTigoKC05Ny4xNDYzIDI1Ljk1NTYsLTk3LjIwOCAyNS45NjM2LC05Ny4yNzcyIDI1LjkzNTQsLTk3LjM0ODkgMjUuOTMwOCwtOTcuMzc0NCAyNS45MDc0LC05Ny4zNTc2IDI1Ljg4NjksLTk3LjM3MzcgMjUuODQsLTk3LjQ1MzkgMjUuODU0NCwtOTcuNDU2NCAyNS44ODM4LC05Ny41MjE4IDI1Ljg4NjUsLTk3LjU0ODIgMjUuOTM1NSwtOTcuNTgyNiAyNS45Mzc5LC05Ny42NDQ5IDI2LjAyNzUsLTk3LjcwNjcgMjYuMDM3NCwtOTcuNzY0MSAyNi4wMjg2LC05Ny44MDEzIDI2LjA2LC05Ny44MzU1IDI2LjA0NjksLTk3Ljg2MTkgMjYuMDY5OCwtOTcuOTA5OSAyNi4wNTY5LC05Ny45NjYxIDI2LjA1MTksLTk4LjAzMDggMjYuMDY1LC05OC4wNzAxIDI2LjAzNzksLTk4LjA3OTEgMjYuMDcwNSwtOTguMTM1NSAyNi4wNzIsLTk4LjE1NzUgMjYuMDU0NCwtOTguMTk3IDI2LjA1NjIsLTk4LjMwNjUgMjYuMTA0MywtOTguMzM1MiAyNi4xMzc2LC05OC4zODY3IDI2LjE1NzksLTk4LjQ0NDMgMjYuMjAxMiwtOTguNDQ1MiAyNi4yMjQ2LC05OC41MDYxIDI2LjIwOSwtOTguNTIyNCAyNi4yMjA5LC05OC41NjE1IDI2LjIyNDUsLTk4LjU4NjcgMjYuMjU3NSwtOTguNjU0MiAyNi4yMzYsLTk4LjY3OTQgMjYuMjQ5MiwtOTguNzUzOCAyNi4zMzE3LC05OC43ODk4IDI2LjMzMTYsLTk4LjgyNjkgMjYuMzY5NiwtOTguODk2MiAyNi4zNTMyLC05OC45MjkyIDI2LjM5MzIsLTk4Ljk0NjUgMjYuMzY5OSwtOTguOTc0MiAyNi40MDExLC05OS4wMTA2IDI2LjM5MjEsLTk5LjA0IDI2LjQxMjksLTk5LjA5NDggMjYuNDEwOSwtOTkuMTEwOSAyNi40MjYzLC05OS4wOTE2IDI2LjQ3NjQsLTk5LjEyODQgMjYuNTI1NSwtOTkuMTY2NyAyNi41MzYxLC05OS4xNjk0IDI2LjU3MTcsLTk5LjIwMDIgMjYuNjU1OCwtOTkuMjA4OSAyNi43MjQ4LC05OS4yNCAyNi43NDU5LC05OS4yNDI0IDI2Ljc4ODMsLTk5LjI2ODYgMjYuODQzMiwtOTkuMzI4OSAyNi44ODAyLC05OS4zMjE4IDI2LjkwNjgsLTk5LjM4ODMgMjYuOTQ0MiwtOTkuMzc3MyAyNi45NzM4LC05OS40MTU1IDI3LjAxNzIsLTk5LjQ0NjUgMjcuMDIzLC05OS40NTEgMjcuMDY2OCwtOTkuNDMwMyAyNy4wOTQ5LC05OS40Mzk2IDI3LjE1MjEsLTk5LjQyNjQgMjcuMTc4MywtOTkuNDUzOCAyNy4yNjUxLC05OS40OTY2IDI3LjI3MTcsLTk5LjQ5NSAyNy4zMDM5LC05OS41Mzc5IDI3LjMxNzUsLTk5LjUwNDQgMjcuMzM5OSwtOTkuNDgwNCAyNy40ODE2LC05OS41MjgzIDI3LjQ5ODksLTk5LjUxMTEgMjcuNTY0NSwtOTkuNTU2OCAyNy42MTQzLC05OS41OCAyNy42MDIzLC05OS41OTQgMjcuNjM4NiwtOTkuNjM4OSAyNy42MjY4LC05OS42OTEzIDI3LjY2ODcsLTk5LjcyODQgMjcuNjc5MywtOTkuNzcwNyAyNy43MzIxLC05OS44MzMxIDI3Ljc2MjksLTk5Ljg3MjMgMjcuNzk1MywtOTkuODgxMyAyNy44NDk2LC05OS45MDE1IDI3Ljg2NDIsLTk5LjkwMDEgMjcuOTEyMSwtOTkuOTM3MSAyNy45NDA1LC05OS45MzE4IDI3Ljk4MSwtOTkuOTg5OCAyNy45OTI5LC0xMDAuMDE5IDI4LjA2NjQsLTEwMC4wNTYxIDI4LjA5MTMsLTEwMC4wODY5IDI4LjE0NjgsLTEwMC4xNTkyIDI4LjE2NzYsLTEwMC4yMTIyIDI4LjE5NjgsLTEwMC4yMjM2IDI4LjIzNTIsLTEwMC4yNTc4IDI4LjI0MDMsLTEwMC4yOTM1IDI4LjI3ODUsLTEwMC4yODg2IDI4LjMxNywtMTAwLjM0OTMgMjguNDAxNCwtMTAwLjMzNjIgMjguNDMwMiwtMTAwLjM2ODIgMjguNDc4OSwtMTAwLjMzNDcgMjguNTAwMywtMTAwLjM4NyAyOC41MTQsLTEwMC40MTA0IDI4LjU1NDMsLTEwMC4zOTg1IDI4LjU4NTIsLTEwMC40NDc2IDI4LjYxMDEsLTEwMC40NDU3IDI4LjY0MDYsLTEwMC41MDA0IDI4LjY2MiwtMTAwLjUwNzYgMjguNzQwNiwtMTAwLjUzMzYgMjguNzYxMSwtMTAwLjU0NjYgMjguODI0OSwtMTAwLjU3MDUgMjguODI2MywtMTAwLjU5MTUgMjguODg5MywtMTAwLjY0ODggMjguOTQxLC0xMDAuNjQ1OSAyOC45ODY0LC0xMDAuNjY3NSAyOS4wODQzLC0xMDAuNzc1OSAyOS4xNzMzLC0xMDAuNzY1OSAyOS4xODc1LC0xMDAuNzk0OCAyOS4yNDE2LC0xMDAuODc2MSAyOS4yNzk2LC0xMDAuODg2OCAyOS4zMDc4LC0xMDAuOTUwNyAyOS4zNDc3LC0xMDEuMDA2NiAyOS4zNjYsLTEwMS4wNjAyIDI5LjQ1ODcsLTEwMS4xNTE5IDI5LjQ3NywtMTAxLjE3MzggMjkuNTE0NiwtMTAxLjI2MTIgMjkuNTM2OCwtMTAxLjI0MSAyOS41NjUsLTEwMS4yNjIyIDI5LjYzMDYsLTEwMS4yOTEgMjkuNTcxNSwtMTAxLjMxMTYgMjkuNTg1MSwtMTAxLjMgMjkuNjQwNywtMTAxLjMxNDEgMjkuNjU5MSwtMTAxLjM2MzIgMjkuNjUyNiwtMTAxLjM3NTQgMjkuNzAxOCwtMTAxLjQxNTYgMjkuNzQ2NSwtMTAxLjQ0ODkgMjkuNzUwNywtMTAxLjQ1NTggMjkuNzg4LC0xMDEuNTM5MiAyOS43NjE4LC0xMDEuNTQxOSAyOS44MTA4LC0xMDEuNTc1OCAyOS43NjkzLC0xMDEuNzEwNiAyOS43NjE3LC0xMDEuNzYwOSAyOS43ODIxLC0xMDEuODA2MiAyOS43ODA4LC0xMDEuODUzNCAyOS44MDc5LC0xMDEuOTMzNSAyOS43ODUxLC0xMDIuMDM4MyAyOS44MDMxLC0xMDIuMDQ5IDI5Ljc4NTYsLTEwMi4xMTYxIDI5Ljc5MjUsLTEwMi4xOTQ5IDI5LjgzNzEsLTEwMi4zMjA3IDI5Ljg3ODksLTEwMi4zNjQ4IDI5Ljg0NDMsLTEwMi4zODk3IDI5Ljc4MTksLTEwMi41MTc0IDI5Ljc4MzgsLTEwMi41NDggMjkuNzQ1LC0xMDIuNTcyNCAyOS43NTYxLC0xMDIuNjIzIDI5LjczNjQsLTEwMi42NzQ5IDI5Ljc0NDMsLTEwMi42OTM0IDI5LjY3NzIsLTEwMi43NDIyIDI5LjYzMDcsLTEwMi43NDUgMjkuNTkzMiwtMTAyLjc2ODMgMjkuNTk0NywtMTAyLjc3MTQgMjkuNTQ4OSwtMTAyLjgwODQgMjkuNTIyOSwtMTAyLjgzMSAyOS40NDQzLC0xMDIuODI0NyAyOS4zOTczLC0xMDIuODM5OSAyOS4zNjA2LC0xMDIuODc4NiAyOS4zNTM5LC0xMDIuOTAzMiAyOS4yNTQsLTEwMi44NzA2IDI5LjIzNjksLTEwMi44OTAxIDI5LjIwODgsLTEwMi45NTAyIDI5LjE3MzYsLTEwMi45NzM4IDI5LjE4NTUsLTEwMy4wMzI1IDI5LjEwNDcsLTEwMy4wNzUzIDI5LjA5MjMsLTEwMy4xMDA3IDI5LjA2MDIsLTEwMy4xMTUzIDI4Ljk4NTMsLTEwMy4xNTMzIDI4Ljk3MTgsLTEwMy4yMjc0IDI4Ljk5MTUsLTEwMy4yNzkyIDI4Ljk3NzcsLTEwMy4yOTg2IDI5LjAwNjgsLTEwMy40MzM3IDI5LjA0NSwtMTAzLjQ1MDYgMjkuMDcyOCwtMTAzLjU1NDUgMjkuMTU4NSwtMTAzLjcxOTIgMjkuMTgxNCwtMTAzLjc5MjcgMjkuMjYyMywtMTAzLjgxNDcgMjkuMjczOCwtMTAzLjk2OTYgMjkuMjk3OCwtMTA0LjAxOTkgMjkuMzEyMSwtMTA0LjEwNjUgMjkuMzczMSwtMTA0LjE2MyAyOS4zOTE5LC0xMDQuMjE3NSAyOS40NTU5LC0xMDQuMjA5IDI5LjQ4MSwtMTA0LjI2NDIgMjkuNTE0LC0xMDQuMzM4MSAyOS41MiwtMTA0LjQwMDYgMjkuNTczLC0xMDQuNDY2OSAyOS42MDk2LC0xMDQuNTQ0MiAyOS42ODE2LC0xMDQuNTY2MSAyOS43NzE0LC0xMDQuNjI5NSAyOS44NTIzLC0xMDQuNjgyNSAyOS45MzQ4LC0xMDQuNjc0IDI5Ljk1NjcsLTEwNC43MDYzIDMwLjA0OTcsLTEwNC42ODc5IDMwLjA3MzksLTEwNC42OTY2IDMwLjEzNDQsLTEwNC42ODcyIDMwLjE3OSwtMTA0LjcwNjggMzAuMjM1NCwtMTA0Ljc2MzIgMzAuMjc0NCwtMTA0Ljc3MzUgMzAuMzAyNywtMTA0LjgyMjYgMzAuMzUwMywtMTA0LjgxNjMgMzAuMzc0MywtMTA0Ljg1OTUgMzAuMzkxMSwtMTA0Ljg2OTQgMzAuNDc3MywtMTA0Ljg4MjQgMzAuNTMyMywtMTA0LjkxOSAzMC41OTc3LC0xMDQuOTcyMSAzMC42MTAzLC0xMDUuMDA2NSAzMC42ODU4LC0xMDUuMDYyNSAzMC42ODY2LC0xMDUuMTE4MSAzMC43NDk1LC0xMDUuMTYxNyAzMC43NTIxLC0xMDUuMjE3NyAzMC44MDYsLTEwNS4yNTYxIDMwLjc5NDUsLTEwNS4yOTE3IDMwLjgyNjEsLTEwNS4zNjE1IDMwLjg1MDMsLTEwNS4zOTU2IDMwLjg0OSwtMTA1LjQxMzUgMzAuODk5OCwtMTA1LjQ5ODggMzAuOTUwMywtMTA1LjU3ODYgMzEuMDIwNiwtMTA1LjU4NTEgMzEuMDU2OSwtMTA1LjY0NjcgMzEuMTEzOSwtMTA1Ljc3MzkgMzEuMTY4LC0xMDUuODE4OCAzMS4yMzA3LC0xMDUuODc0NyAzMS4yOTEzLC0xMDUuOTMxMiAzMS4zMTI3LC0xMDUuOTUzOSAzMS4zNjQ3LC0xMDYuMDE2MiAzMS4zOTM1LC0xMDYuMDc1MyAzMS4zOTc2LC0xMDYuMTkxMSAzMS40NTk5LC0xMDYuMjE5NiAzMS40ODE2LC0xMDYuMjQ1MiAzMS41MzkxLC0xMDYuMjgwMSAzMS41NjE1LC0xMDYuMzA3OSAzMS42Mjk1LC0xMDYuMzgxMSAzMS43MzIxLC0xMDYuNDUxNCAzMS43NjQ0LC0xMDYuNDkwNSAzMS43NDg5LC0xMDYuNTI4MiAzMS43ODMxLC0xMDYuNTQ3MSAzMS44MDczLC0xMDYuNjA1MyAzMS44Mjc3LC0xMDYuNjQ1NSAzMS44OTg3LC0xMDYuNjExOCAzMS45MiwtMTA2LjYxODUgMzIuMDAwNSwtMTA1Ljk5OCAzMi4wMDIzLC0xMDUuMjUwNSAzMi4wMDAzLC0xMDQuODQ3OCAzMi4wMDA1LC0xMDQuMDI0NSAzMiwtMTAzLjA2NDQgMzIuMDAwNSwtMTAzLjA2NDcgMzIuOTU5MSwtMTAzLjA1NjcgMzMuMzg4NCwtMTAzLjA0NCAzMy45NzQ2LC0xMDMuMDQyNCAzNS4xODMxLC0xMDMuMDQwOCAzNi4wNTUyLC0xMDMuMDQxOSAzNi41MDA0LC0xMDMuMDAyNCAzNi41MDA0LC0xMDIuMDMyMyAzNi41MDA2LC0xMDEuNjIzOSAzNi40OTk1LC0xMDEuMDg1MiAzNi40OTkyLC0xMDAuMDAwNCAzNi40OTk3LC0xMDAuMDAwNCAzNC43NDY1LC05OS45OTc1IDM0LjU2MDYsLTk5LjkyMzIgMzQuNTc0NiwtOTkuODQ0NiAzNC41MDY5LC05OS43NTM0IDM0LjQyMDksLTk5LjY5NDUgMzQuMzc4MiwtOTkuNiAzNC4zNzQ3LC05OS41Nzk4IDM0LjQxNjksLTk5LjUxNzYgMzQuNDE0NSwtOTkuNDMzNSAzNC4zNzAyLC05OS4zOTg3IDM0LjM3NTgsLTk5LjM5NTIgMzQuNDQyLC05OS4zNzU2IDM0LjQ1ODgsLTk5LjMyMDEgMzQuNDA5MywtOTkuMjYxMyAzNC40MDM1LC05OS4yMTA4IDM0LjMzNjgsLTk5LjE4OTggMzQuMjE0NCwtOTkuMDk1MyAzNC4yMTE4LC05OS4wNDM0IDM0LjE5ODIsLTk4Ljk5MTcgMzQuMjIxNCwtOTguOTUyNCAzNC4yMTI1LC05OC44NjAxIDM0LjE0OTksLTk4LjgzMTEgMzQuMTYyMiwtOTguNzY2NyAzNC4xMzY4LC05OC42OTAxIDM0LjEzMzIsLTk4LjY0ODEgMzQuMTY0NCwtOTguNjEwMiAzNC4xNTcxLC05OC41NjAyIDM0LjEzMzIsLTk4LjQ4NyAzNC4wNjI5LC05OC40MjM1IDM0LjA4MjgsLTk4LjM5ODQgMzQuMTI4NSwtOTguMzY0IDM0LjE1NzEsLTk4LjMwMDIgMzQuMTM0NiwtOTguMjMyNSAzNC4xMzQ2LC05OC4xNjg4IDM0LjExNDMsLTk4LjEzOTEgMzQuMTQxOSwtOTguMTAxOSAzNC4xNDY4LC05OC4wOTA1IDM0LjEyMjUsLTk4LjEyMDIgMzQuMDcyMSwtOTguMDgzOCAzNC4wNDE3LC05OC4wODQ0IDM0LjAwMjksLTk4LjAxNjMgMzMuOTk0MSwtOTcuOTc0MiAzNC4wMDY3LC05Ny45NDY4IDMzLjk5MDksLTk3Ljk3MTIgMzMuOTM3MiwtOTcuOTU3MiAzMy45MTQ1LC05Ny45Nzc5IDMzLjg4OTksLTk3Ljg3MTQgMzMuODQ5LC05Ny44MzQzIDMzLjg1NzcsLTk3Ljc2MyAzMy45MzQxLC05Ny43MzIzIDMzLjkzNjcsLTk3LjY4NzcgMzMuOTg3MiwtOTcuNjYxNSAzMy45OTA4LC05Ny41ODg4IDMzLjk1MTksLTk3LjU4OTMgMzMuOTAzOSwtOTcuNTYwOSAzMy44OTk2LC05Ny40ODQyIDMzLjkxNTQsLTk3LjQ1MTEgMzMuODkxNywtOTcuNDYyOSAzMy44NDI5LC05Ny40NDM5IDMzLjgyMzcsLTk3LjM3MjkgMzMuODE5NSwtOTcuMzMxOSAzMy44ODQ1LC05Ny4yNTU2IDMzLjg2MzcsLTk3LjI0NjIgMzMuOTAwMywtOTcuMjEwMyAzMy45MTU5LC05Ny4xODU1IDMzLjkwMDcsLTk3LjE2NjggMzMuODQwNCwtOTcuMTk3NCAzMy44Mjk4LC05Ny4xOTM0IDMzLjc2MDYsLTk3LjE1MTMgMzMuNzIyNiwtOTcuMTExMSAzMy43MTk0LC05Ny4wODg3IDMzLjczODcsLTk3LjA4OCAzMy44MDg3LC05Ny4wNDggMzMuODE3OSwtOTcuMDg3MyAzMy44Mzk4LC05Ny4wNTczIDMzLjg1NjksLTk3LjAyMzUgMzMuODQ0NSwtOTYuOTg1NiAzMy44ODY1LC05Ni45OTYzIDMzLjk0MjcsLTk2LjkzNDggMzMuOTU0NSwtOTYuODk5NCAzMy45MzM3LC05Ni44ODMgMzMuODY4LC05Ni44NTA2IDMzLjg0NzIsLTk2LjgzMjIgMzMuODc0OCwtOTYuNzc5NiAzMy44NTc5LC05Ni43Njk0IDMzLjgyNzUsLTk2LjcxMzcgMzMuODMxMywtOTYuNjkwNyAzMy44NSwtOTYuNjczNCAzMy45MTIzLC05Ni41ODg1IDMzLjg5NSwtOTYuNjI5IDMzLjg1MjQsLTk2LjU3MzIgMzMuODE5MiwtOTYuNTMyOSAzMy44MjMsLTk2LjUwMDcgMzMuNzcyNiwtOTYuNDIyNiAzMy43NzYsLTk2LjM3OTUgMzMuNzI1OCwtOTYuMzYyMiAzMy42OTE4LC05Ni4zMTg0IDMzLjY5NzEsLTk2LjMwMyAzMy43NTA5LC05Ni4yNzczIDMzLjc2OTcsLTk2LjIzMDQgMzMuNzQ4NSwtOTYuMTc4MSAzMy43NjA1LC05Ni4xNDkyIDMzLjgzNzEsLTk2LjEwMTUgMzMuODQ2NywtOTYuMDQ4OCAzMy44MzY1LC05NS45NDE5IDMzLjg2MSwtOTUuOTMyMSAzMy44ODY1LC05NS44NDMzIDMzLjgzODMsLTk1LjgwNDUgMzMuODYyMiwtOTUuNzY3OSAzMy44NDY4LC05NS43NTY2IDMzLjg5MiwtOTUuNjk0OSAzMy44ODY4LC05NS42Njg2IDMzLjkwNywtOTUuNjI3MyAzMy45MDc4LC05NS41OTc1IDMzLjk0MjMsLTk1LjU1NzcgMzMuOTMwNCwtOTUuNTQzNCAzMy44ODA1LC05NS40NTk4IDMzLjg4OCwtOTUuNDM4MiAzMy44NjcxLC05NS4zMTA1IDMzLjg3NzIsLTk1LjI4MjIgMzMuODc1OSwtOTUuMjcxNCAzMy45MTI2LC05NS4yMTk0IDMzLjk2MTYsLTk1LjE1NTkgMzMuOTM2OCwtOTUuMTI5NiAzMy45MzY3LC05NS4xMTc2IDMzLjkwNDYsLTk1LjA4MjQgMzMuODc5OSwtOTUuMDYwMSAzMy45MDE5LC05NS4wNDkgMzMuODY0MSwtOTQuOTY4OSAzMy44NjA5LC05NC45NTM1IDMzLjgxNjUsLTk0LjkyMzMgMzMuODA4NywtOTQuOTExNSAzMy43Nzg0LC05NC44NDkzIDMzLjczOTYsLTk0LjgyMzQgMzMuNzY5MiwtOTQuODAyMyAzMy43MzI4LC05NC43NzEzIDMzLjc2MDcsLTk0Ljc0NjEgMzMuNzAzLC05NC42ODQ4IDMzLjY4NDQsLTk0LjY2NzkgMzMuNjk0NiwtOTQuNjM5MiAzMy42NjM3LC05NC42MjE0IDMzLjY4MjYsLTk0LjU5MDggMzMuNjQ1NiwtOTQuNTQ2NCAzMy42NiwtOTQuNTIwNCAzMy42MTc1LC05NC40ODU5IDMzLjYzNzksLTk0LjM4OTUgMzMuNTQ2NywtOTQuMzUzNiAzMy41NDQsLTk0LjM0NTUgMzMuNTY3MywtOTQuMzA5NiAzMy41NTE3LC05NC4yNzU5IDMzLjU1OCwtOTQuMjE5MiAzMy41NTYxLC05NC4xODQzIDMzLjU5NDYsLTk0LjE0NzQgMzMuNTY1MiwtOTQuMDgyNCAzMy41NzU3LC05NC4wNDM0IDMzLjU1MjMsLTk0LjA0MyAzMy4wMTkyLC05NC4wNDI3IDMxLjk5OTMsLTk0LjAxNTYgMzEuOTc5OSwtOTMuOTcwOCAzMS45MiwtOTMuOTI5OSAzMS45MTI3LC05My44OTY3IDMxLjg4NTMsLTkzLjg3NDggMzEuODIyMywtOTMuODIyNiAzMS43NzM2LC05My44MzY5IDMxLjc1MDIsLTkzLjc5NDUgMzEuNzAyMSwtOTMuODIxNyAzMS42NzQsLTkzLjgxODcgMzEuNjE0NiwtOTMuODM0OSAzMS41ODYyLC05My43ODUgMzEuNTI2LC05My43MTI1IDMxLjUxMzQsLTkzLjc0OTUgMzEuNDY4NywtOTMuNjkyNiAzMS40MzcyLC05My43MDQ5IDMxLjQxMDksLTkzLjY3NDEgMzEuMzk3NywtOTMuNjY5MSAzMS4zNjU0LC05My42ODc1IDMxLjMxMDgsLTkzLjU5ODQgMzEuMjMxMSwtOTMuNjAwMyAzMS4xNzYyLC05My41NTI2IDMxLjE4NTYsLTkzLjUzOTQgMzEuMTE1MiwtOTMuNTYzMiAzMS4wOTcsLTkzLjUyNzYgMzEuMDc0NSwtOTMuNTA4OSAzMS4wMjkzLC05My41NTYzIDMxLjAwNDEsLTkzLjU2ODQgMzAuOTY5MSwtOTMuNTMyMSAzMC45NTc5LC05My41MjYzIDMwLjkyOTcsLTkzLjU1ODYgMzAuOTEzMiwtOTMuNTUzNiAzMC44MzUxLC05My42MTQ4IDMwLjc1NiwtOTMuNjA3NyAzMC43MTU2LC05My42MzE1IDMwLjY3OCwtOTMuNjgzMSAzMC42NDA4LC05My42Nzg4IDMwLjU5ODYsLTkzLjcyNzUgMzAuNTc0NywtOTMuNzMzOCAzMC41MzE3LC05My42OTc4IDMwLjQ0MzgsLTkzLjc0MTcgMzAuNDAyMywtOTMuNzYyMyAzMC4zNTM3LC05My43NDIxIDMwLjMwMSwtOTMuNzA0NyAzMC4yODk5LC05My43MDcgMzAuMjQzNywtOTMuNzIxIDMwLjIxMDQsLTkzLjY5MjggMzAuMTM1MiwtOTMuNzMyOCAzMC4wODI5LC05My43MjI1IDMwLjA1MDksLTkzLjc1NTEgMzAuMDE1MywtOTMuODcxNyAyOS45ODEsLTkzLjg2OTIgMjkuOTM4LC05My45NTA2IDI5Ljg0OTMsLTkzLjk0NjYgMjkuNzgwMSwtOTMuODM3NyAyOS42NzksLTk0LjAxNDMgMjkuNjc5OCwtOTQuMzU0MyAyOS41NjEsLTk0LjQ5OTEgMjkuNTA2OCwtOTQuNDcwMiAyOS41NTcxLC05NC41NDU5IDI5LjU3MjUsLTk0Ljc2MjUgMjkuNTI0MSwtOTQuNzAzOSAyOS42MzI1LC05NC42OTU3IDI5Ljc1NjUsLTk0LjczODkgMjkuNzkwNiwtOTQuODE0MSAyOS43NTksLTk0Ljg3MjggMjkuNjcxNCwtOTQuOTMwMyAyOS42NzM3LC05NS4wMTY2IDI5LjcyMDUsLTk1LjA3MjYgMjkuODI2MiwtOTUuMDk1NSAyOS43NTc2LC05NC45ODMzIDI5LjY4MjMsLTk0Ljk5ODUgMjkuNjE2NCwtOTUuMDc4OSAyOS41MzUzLC05NS4wMTcgMjkuNTQ4LC05NC45MDk2IDI5LjQ5NjEsLTk0Ljk1MDQgMjkuNDY2NywtOTQuODg1NCAyOS4zODk3LC05NS4wNTc0IDI5LjIwMTMsLTk1LjE0OTYgMjkuMTgwNSwtOTUuMjM0MiAyOC45OTI2LC05NS4zODU2IDI4Ljg2NDYsLTk1LjUwNzIgMjguODI1NCwtOTUuNjUzNyAyOC43NDk5LC05NS42NzI3IDI4Ljc0OTUsLTk1Ljc4NCAyOC42Nzk0LC05NS45MTQ5IDI4LjYzODgsLTk1LjY3NzYgMjguNzQ5NCwtOTUuNzg1MyAyOC43NDcxLC05NS45MjM2IDI4LjcwMTUsLTk1Ljk2MDggMjguNjE1MiwtOTYuMzM1NSAyOC40MzgxLC05Ni4xNDYzIDI4LjU0MjcsLTk1Ljk5MDYgMjguNjAxNiwtOTYuMDM4OCAyOC42NTI4LC05Ni4xNTI0IDI4LjYxMzUsLTk2LjIzNTQgMjguNjQyNywtOTYuMjA3OCAyOC42OTgxLC05Ni4zMjI5IDI4LjY0MTksLTk2LjM4NiAyOC42NzQ4LC05Ni40Mjg0IDI4LjcwNzEsLTk2LjQzNDggMjguNjAzLC05Ni41NjE1IDI4LjY0NTQsLTk2LjU3MzYgMjguNzA1NSwtOTYuNjU5NiAyOC43MjI2LC05Ni42NjE0IDI4LjcwMjYsLTk2LjYxMjEgMjguNjM5NCwtOTYuNjM4NSAyOC41NzE5LC05Ni41NjY3IDI4LjU4MjUsLTk2LjQxNTMgMjguNDYzNywtOTYuNDMyMiAyOC40MzI1LC05Ni42NTAzIDI4LjMzMjUsLTk2LjcwODQgMjguNDA3NSwtOTYuNzg1NyAyOC40NDc2LC05Ni43ODMyIDI4LjQwMDQsLTk2Ljg1ODkgMjguNDE3NiwtOTYuNzkwNSAyOC4zMTkyLC05Ni44MDk1IDI4LjIxOTksLTk2LjkxMTEgMjguMTM1NywtOTYuOTg2OCAyOC4xMjg3LC05Ny4wMzczIDI4LjIwMTMsLTk3LjI0MTUgMjguMDYyMywtOTcuMTUgMjguMDMzOCwtOTcuMTM1NCAyOC4wNDcyLC05Ny4wMjQ2IDI4LjExMzMsLTk3LjAzMSAyOC4wNDg2LC05Ny4xMzM4IDI3LjkwMDksLTk3LjE1NjkgMjcuODcyOCwtOTcuMjEzNCAyNy44MjEsLTk3LjI1MDEgMjcuODc2NCwtOTcuMzU0OCAyNy44NTAyLC05Ny4zMzEyIDI3Ljg3MzgsLTk3LjUyODEgMjcuODQ3NCwtOTcuMzgyOSAyNy44Mzg3LC05Ny4zNjE3IDI3LjczNTEsLTk3LjI0NSAyNy42OTMxLC05Ny4zMjQ4IDI3LjU2MSwtOTcuNDEyMyAyNy4zMjI0LC05Ny41MDExIDI3LjI5MTUsLTk3LjQ3MzcgMjcuNDAyOSwtOTcuNTMzOSAyNy4zMzk4LC05Ny42Mzc0IDI3LjMwMSwtOTcuNzM1MiAyNy40MTgyLC05Ny42NjE5IDI3LjI4NzUsLTk3Ljc5NjYgMjcuMjcyNiwtOTcuNjU3NCAyNy4yNzM3LC05Ny41MzQxIDI3LjIyNTMsLTk3LjQ0ODcgMjcuMjYzMSwtOTcuNDUxMSAyNy4xMjE2LC05Ny41MDUyIDI3LjA4NTYsLTk3LjQ3OSAyNi45OTkxLC05Ny41NjE0IDI2Ljk5OCwtOTcuNTYyOSAyNi44Mzg5LC05Ny40NzEgMjYuNzUwMSwtOTcuNDQ2NCAyNi41OTk5LC05Ny40MTc3IDI2LjM3MDIsLTk3LjM0MDYgMjYuMzMxOCwtOTcuMjk1NSAyNi4xOTA4LC05Ny4zMTIxIDI2LjEyMTYsLTk3LjIzNjUgMjYuMDY0NiwtOTcuMjUxNiAyNS45NjQzLC05Ny4xNTI3IDI2LjAyNzUsLTk3LjE0NjMgMjUuOTU1NikpLCgoLTk0LjUxMTcgMjkuNTE1OCwtOTQuNjU5MiAyOS40Mzc1LC05NC43MjgyIDI5LjM3MTYsLTk0Ljc3NzQgMjkuMzc1OSwtOTQuNjg1MiAyOS40NTEzLC05NC41MTE3IDI5LjUxNTgpKSwoKC05NC43NTE4IDI5LjMzMjksLTk0LjgwNDkgMjkuMjc4NywtOTUuMDU2MiAyOS4xMjk5LC05NC44NjEzIDI5LjI5NTMsLTk0Ljc1MTggMjkuMzMyOSkpLCgoLTk2LjgyMDEgMjguMTY0NSwtOTYuNzAzNyAyOC4xOTgsLTk2LjM4NzUgMjguMzc2MiwtOTYuNDQwMyAyOC4zMTg4LC05Ni42ODc4IDI4LjE4NTksLTk2Ljg0NzkgMjguMDY1MSwtOTYuODIwMSAyOC4xNjQ1KSksKCgtOTYuODcyMiAyOC4xMzE1LC05Ni44NSAyOC4wNjM4LC05Ny4wNTU0IDI3Ljg0NzIsLTk2Ljk2MzIgMjguMDIyOSwtOTYuODcyMiAyOC4xMzE1KSksKCgtOTcuMjk0MyAyNi42MDAzLC05Ny4zMjU0IDI2LjYwMDMsLTk3LjMwOTQgMjYuNjI5OCwtOTcuMzkyMSAyNi45MzY3LC05Ny4zOTE2IDI3LjEyNTgsLTk3LjM2NjEgMjcuMjc4MSwtOTcuMzcxMiAyNy4yNzgxLC05Ny4zMzAyIDI3LjQzNTIsLTk3LjI0NzIgMjcuNTgxNSwtOTcuMTk2NCAyNy42ODM3LC05Ny4wOTI1IDI3LjgxMTQsLTk3LjA0NDYgMjcuODM0NCwtOTcuMTUwNCAyNy43MDI3LC05Ny4yMjI3IDI3LjU3NjUsLTk3LjM0NzIgMjcuMjc4LC05Ny4zNzkzIDI3LjA0MDIsLTk3LjM3MDUgMjYuOTA4MSwtOTcuMjkwMSAyNi42MDAzLC05Ny4yOTQzIDI2LjYwMDMpKSkmcXVvdDs8L3ZhbHVlPgogICAgICAgICAgICAgIDx2YWx1ZT4zMS4yNTwvdmFsdWU-CiAgICAgICAgICAgICAgPHZhbHVlPi05OS4yNTwvdmFsdWU-CiAgICAgICAgICAgICAgPHZhbHVlPi0wLjE1MTE4MTkyNDU1MzI0NTk0PC92YWx1ZT4KICAgICAgICAgICAgPC90dXBsZT4KICAgICAgICAgIDwvdHVwbGUtcmVmZXJlbmNlPgogICAgICAgIDwvdHVwbGUtc2VsZWN0aW9uPgogICAgICA8L3NlbGVjdGlvbi1jb2xsZWN0aW9uPgogICAgPC93aW5kb3c-CiAgPC93aW5kb3dzPgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "Product", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nUHJvZHVjdCcgc291cmNlLWJ1aWxkPScyMDI0LjIuMCAoMjAyNDIuMjQuMDcxNi4xOTQ0KScgdmVyc2lvbj0nMTguMScgeG1sbnM6dXNlcj0naHR0cDovL3d3dy50YWJsZWF1c29mdHdhcmUuY29tL3htbC91c2VyJz4KICA8YWN0aXZlIGlkPSctMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjaic-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKENhdGVnb3J5LFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKENhdGVnb3J5LFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYXRlZ29yeV0nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbeXI6T3JkZXIgRGF0ZTpva10nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbbW46T3JkZXIgRGF0ZTpva10nIC8-CiAgICAgICAgPC9ncm91cGZpbHRlcj4KICAgICAgPC9ncm91cD4KICAgICAgPGNvbHVtbiBjYXB0aW9uPSdBY3Rpb24gKENhdGVnb3J5LFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoQ2F0ZWdvcnksWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1t5cjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1ttbjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSknIGRhdGF0eXBlPSd0dXBsZScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChZRUFSKE9yZGVyIERhdGUpLE1PTlRIKE9yZGVyIERhdGUpKV0nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluaycgLz4KICAgICAgPGdyb3VwIGNhcHRpb249J0FjdGlvbiAoWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChZRUFSKE9yZGVyIERhdGUpLE1PTlRIKE9yZGVyIERhdGUpLFByb2R1Y3QgQ2F0ZWdvcnkpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1t5cjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1ttbjpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tub25lOkNhdGVnb3J5Om5rXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoWUVBUihPcmRlciBEYXRlKSxNT05USChPcmRlciBEYXRlKSxQcm9kdWN0IENhdGVnb3J5KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFlFQVIoT3JkZXIgRGF0ZSksTU9OVEgoT3JkZXIgRGF0ZSksUHJvZHVjdCBDYXRlZ29yeSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J01vbnRoJyBuYW1lPSdbbW46T3JkZXIgRGF0ZTpva10nIHBpdm90PSdrZXknIHR5cGU9J29yZGluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbQ2F0ZWdvcnldJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpDYXRlZ29yeTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6cWtdJyBwaXZvdD0na2V5JyB0eXBlPSdxdWFudGl0YXRpdmUnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbUmVnaW9uXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6UmVnaW9uOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nWWVhcicgbmFtZT0nW3lyOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdQcm9kdWN0Vmlldyc-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J1Byb2R1Y3REZXRhaWxzJz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "Customers", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nQ3VzdG9tZXJzJyBzb3VyY2UtYnVpbGQ9JzIwMjQuMi4wICgyMDI0Mi4yNC4wNzE2LjE5NDQpJyB2ZXJzaW9uPScxOC4xJyB4bWxuczp1c2VyPSdodHRwOi8vd3d3LnRhYmxlYXVzb2Z0d2FyZS5jb20veG1sL3VzZXInPgogIDxhY3RpdmUgaWQ9Jy0xJyAvPgogIDxkYXRhc291cmNlcz4KICAgIDxkYXRhc291cmNlIG5hbWU9J2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqJz4KICAgICAgPGNvbHVtbiBkYXRhdHlwZT0nc3RyaW5nJyBuYW1lPSdbOk1lYXN1cmUgTmFtZXNdJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnPgogICAgICAgIDxhbGlhc2VzPgogICAgICAgICAgPGFsaWFzIGtleT0nJnF1b3Q7W2ZlZGVyYXRlZC4xMG5uazhkMXZnbXc4cTE3eXU3NnUwNnBuYmNqXS5bY3RkOkN1c3RvbWVyIE5hbWU6cWtdJnF1b3Q7JyB2YWx1ZT0nQ291bnQgb2YgQ3VzdG9tZXJzJyAvPgogICAgICAgIDwvYWxpYXNlcz4KICAgICAgPC9jb2x1bW4-CiAgICAgIDxncm91cCBjYXB0aW9uPSdBY3Rpb24gKFJlZ2lvbiknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoUmVnaW9uKV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbUmVnaW9uXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoUmVnaW9uKScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFJlZ2lvbildJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbQ2F0ZWdvcnldJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpDYXRlZ29yeTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6cWtdJyBwaXZvdD0na2V5JyB0eXBlPSdxdWFudGl0YXRpdmUnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbUmVnaW9uXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6UmVnaW9uOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tTZWdtZW50XScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6U2VnbWVudDpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J1F1YXJ0ZXInIG5hbWU9J1txcjpPcmRlciBEYXRlOm9rXScgcGl2b3Q9J2tleScgdHlwZT0nb3JkaW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nWWVhcicgbmFtZT0nW3lyOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdDdXN0b21lclNjYXR0ZXInPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdDdXN0b21lclJhbmsnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdDdXN0b21lck92ZXJ2aWV3Jz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "Shipping", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nU2hpcHBpbmcnIHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGFjdGl2ZSBpZD0nLTEnIC8-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChEZWxheWVkPyknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoRGVsYXllZD8pXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl82NDAxMTAzMTcxMjU5NzIzXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoRGVsYXllZD8pJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoRGVsYXllZD8pXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChTaGlwIFN0YXR1cyknIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoU2hpcCBTdGF0dXMpXScgbmFtZS1zdHlsZT0ndW5xdWFsaWZpZWQnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnPgogICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nY3Jvc3Nqb2luJz4KICAgICAgICAgIDxncm91cGZpbHRlciBmdW5jdGlvbj0nbGV2ZWwtbWVtYmVycycgbGV2ZWw9J1tDYWxjdWxhdGlvbl82NDAxMTAzMTcxMjU5NzIzXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoU2hpcCBTdGF0dXMpJyBkYXRhdHlwZT0ndHVwbGUnIGhpZGRlbj0ndHJ1ZScgbmFtZT0nW0FjdGlvbiAoU2hpcCBTdGF0dXMpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChTaGlwIFN0YXR1cyxZRUFSKE9yZGVyIERhdGUpLFdFRUsoT3JkZXIgRGF0ZSkpJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFNoaXAgU3RhdHVzLFlFQVIoT3JkZXIgRGF0ZSksV0VFSyhPcmRlciBEYXRlKSldJyBuYW1lLXN0eWxlPSd1bnF1YWxpZmllZCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluayc-CiAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdjcm9zc2pvaW4nPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW0NhbGN1bGF0aW9uXzY0MDExMDMxNzEyNTk3MjNdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW3lyOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW3R3azpPcmRlciBEYXRlOm9rXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoU2hpcCBTdGF0dXMsWUVBUihPcmRlciBEYXRlKSxXRUVLKE9yZGVyIERhdGUpKScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKFNoaXAgU3RhdHVzLFlFQVIoT3JkZXIgRGF0ZSksV0VFSyhPcmRlciBEYXRlKSldJyByb2xlPSdkaW1lbnNpb24nIHR5cGU9J25vbWluYWwnIHVzZXI6YXV0by1jb2x1bW49J3NoZWV0X2xpbmsnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbQ2FsY3VsYXRpb25fNjQwMTEwMzE3MTI1OTcyM10nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOkNhbGN1bGF0aW9uXzY0MDExMDMxNzEyNTk3MjM6bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbU2hpcCBNb2RlXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6U2hpcCBNb2RlOm5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nUXVhcnRlcicgbmFtZT0nW3FyOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdZZWFyJyBuYW1lPSdbeXI6T3JkZXIgRGF0ZTpva10nIHBpdm90PSdrZXknIHR5cGU9J29yZGluYWwnIC8-CiAgICA8L2RhdGFzb3VyY2U-CiAgPC9kYXRhc291cmNlcz4KICA8d29ya3NoZWV0IG5hbWU9J1NoaXBTdW1tYXJ5Jz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0PgogIDx3b3Jrc2hlZXQgbmFtZT0nU2hpcHBpbmdUcmVuZCc-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J0RheXN0b1NoaXAnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CjwvY3VzdG9taXplZC12aWV3Pgo=" + }, + { + "isSourceView": false, + "viewName": "Performance", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J1llYXInIG5hbWU9J1t5cjpPcmRlciBEYXRlOm9rXScgcGl2b3Q9J2tleScgdHlwZT0nb3JkaW5hbCcgLz4KICAgIDwvZGF0YXNvdXJjZT4KICA8L2RhdGFzb3VyY2VzPgogIDx3b3Jrc2hlZXQgbmFtZT0nUGVyZm9ybWFuY2UnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CjwvY3VzdG9taXplZC12aWV3Pgo=" + }, + { + "isSourceView": false, + "viewName": "Commission Model", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nQ29tbWlzc2lvbiBNb2RlbCcgc291cmNlLWJ1aWxkPScyMDI0LjIuMCAoMjAyNDIuMjQuMDcxNi4xOTQ0KScgdmVyc2lvbj0nMTguMScgeG1sbnM6dXNlcj0naHR0cDovL3d3dy50YWJsZWF1c29mdHdhcmUuY29tL3htbC91c2VyJz4KICA8YWN0aXZlIGlkPSctMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMGEwMWNvZDFveGw4M2wxZjV5dmVzMWNmY2lxbyc-CiAgICAgIDxjb2x1bW4gZGF0YXR5cGU9J3N0cmluZycgbmFtZT0nWzpNZWFzdXJlIE5hbWVzXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdRdW90YUF0dGFpbm1lbnQnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CiAgPHdvcmtzaGVldCBuYW1lPSdDb21taXNzaW9uUHJvamVjdGlvbic-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KICA8d29ya3NoZWV0IG5hbWU9J1NhbGVzJz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0PgogIDx3b3Jrc2hlZXQgbmFtZT0nT1RFJz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "Order Details", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IGRhc2hib2FyZD0nT3JkZXIgRGV0YWlscycgc291cmNlLWJ1aWxkPScyMDI0LjIuMCAoMjAyNDIuMjQuMDcxNi4xOTQ0KScgdmVyc2lvbj0nMTguMScgeG1sbnM6dXNlcj0naHR0cDovL3d3dy50YWJsZWF1c29mdHdhcmUuY29tL3htbC91c2VyJz4KICA8YWN0aXZlIGlkPSctMScgLz4KICA8ZGF0YXNvdXJjZXM-CiAgICA8ZGF0YXNvdXJjZSBuYW1lPSdmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjaic-CiAgICAgIDxjb2x1bW4gZGF0YXR5cGU9J3N0cmluZycgbmFtZT0nWzpNZWFzdXJlIE5hbWVzXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJz4KICAgICAgICA8YWxpYXNlcz4KICAgICAgICAgIDxhbGlhcyBrZXk9JyZxdW90O1tmZWRlcmF0ZWQuMTBubms4ZDF2Z213OHExN3l1NzZ1MDZwbmJjal0uW2N0ZDpDdXN0b21lciBOYW1lOnFrXSZxdW90OycgdmFsdWU9J0NvdW50IG9mIEN1c3RvbWVycycgLz4KICAgICAgICA8L2FsaWFzZXM-CiAgICAgIDwvY29sdW1uPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxNT05USChPcmRlciBEYXRlKSxTZWdtZW50KV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbQ2FsY3VsYXRpb25fOTA2MDEyMjEwNDk0NzQ3MV0nIC8-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbdG1uOk9yZGVyIERhdGU6b2tdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1NlZ21lbnRdJyAvPgogICAgICAgIDwvZ3JvdXBmaWx0ZXI-CiAgICAgIDwvZ3JvdXA-CiAgICAgIDxjb2x1bW4gY2FwdGlvbj0nQWN0aW9uIChPcmRlciBQcm9maXRhYmxlPyxNT05USChPcmRlciBEYXRlKSxTZWdtZW50KScgZGF0YXR5cGU9J3R1cGxlJyBoaWRkZW49J3RydWUnIG5hbWU9J1tBY3Rpb24gKE9yZGVyIFByb2ZpdGFibGU_LE1PTlRIKE9yZGVyIERhdGUpLFNlZ21lbnQpXScgcm9sZT0nZGltZW5zaW9uJyB0eXBlPSdub21pbmFsJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJyAvPgogICAgICA8Z3JvdXAgY2FwdGlvbj0nQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSkgMScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSkgMV0nIG5hbWUtc3R5bGU9J3VucXVhbGlmaWVkJyB1c2VyOmF1dG8tY29sdW1uPSdzaGVldF9saW5rJz4KICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2Nyb3Nzam9pbic-CiAgICAgICAgICA8Z3JvdXBmaWx0ZXIgZnVuY3Rpb249J2xldmVsLW1lbWJlcnMnIGxldmVsPSdbUG9zdGFsIENvZGVdJyAvPgogICAgICAgICAgPGdyb3VwZmlsdGVyIGZ1bmN0aW9uPSdsZXZlbC1tZW1iZXJzJyBsZXZlbD0nW1N0YXRlL1Byb3ZpbmNlXScgLz4KICAgICAgICA8L2dyb3VwZmlsdGVyPgogICAgICA8L2dyb3VwPgogICAgICA8Y29sdW1uIGNhcHRpb249J0FjdGlvbiAoUG9zdGFsIENvZGUsU3RhdGUvUHJvdmluY2UpIDEnIGRhdGF0eXBlPSd0dXBsZScgaGlkZGVuPSd0cnVlJyBuYW1lPSdbQWN0aW9uIChQb3N0YWwgQ29kZSxTdGF0ZS9Qcm92aW5jZSkgMV0nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCcgdXNlcjphdXRvLWNvbHVtbj0nc2hlZXRfbGluaycgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDYXRlZ29yeV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOkNhdGVnb3J5Om5rXScgcGl2b3Q9J2tleScgdHlwZT0nbm9taW5hbCcgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tDaXR5XScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6Q2l0eTpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbT3JkZXIgRGF0ZV0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOk9yZGVyIERhdGU6b2tdJyBwaXZvdD0na2V5JyB0eXBlPSdvcmRpbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpPcmRlciBEYXRlOnFrXScgcGl2b3Q9J2tleScgdHlwZT0ncXVhbnRpdGF0aXZlJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICAgIDxjb2x1bW4taW5zdGFuY2UgY29sdW1uPSdbU2VnbWVudF0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlNlZ21lbnQ6bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1N0YXRlL1Byb3ZpbmNlXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6U3RhdGUvUHJvdmluY2U6bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgPC9kYXRhc291cmNlPgogIDwvZGF0YXNvdXJjZXM-CiAgPHdvcmtzaGVldCBuYW1lPSdQcm9kdWN0IERldGFpbCBTaGVldCc-CiAgICA8dGFibGUgLz4KICA8L3dvcmtzaGVldD4KPC9jdXN0b21pemVkLXZpZXc-Cg==" + }, + { + "isSourceView": false, + "viewName": "Forecast", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpPcmRlciBEYXRlOnFrXScgcGl2b3Q9J2tleScgdHlwZT0ncXVhbnRpdGF0aXZlJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW1JlZ2lvbl0nIGRlcml2YXRpb249J05vbmUnIG5hbWU9J1tub25lOlJlZ2lvbjpua10nIHBpdm90PSdrZXknIHR5cGU9J25vbWluYWwnIC8-CiAgICA8L2RhdGFzb3VyY2U-CiAgPC9kYXRhc291cmNlcz4KICA8d29ya3NoZWV0IG5hbWU9J0ZvcmVjYXN0Jz4KICAgIDx0YWJsZSAvPgogIDwvd29ya3NoZWV0Pgo8L2N1c3RvbWl6ZWQtdmlldz4K" + }, + { + "isSourceView": false, + "viewName": "What If Forecast", + "tcv": "PD94bWwgdmVyc2lvbj0nMS4wJyBlbmNvZGluZz0ndXRmLTgnID8-Cgo8Y3VzdG9taXplZC12aWV3IHNvdXJjZS1idWlsZD0nMjAyNC4yLjAgKDIwMjQyLjI0LjA3MTYuMTk0NCknIHZlcnNpb249JzE4LjEnIHhtbG5zOnVzZXI9J2h0dHA6Ly93d3cudGFibGVhdXNvZnR3YXJlLmNvbS94bWwvdXNlcic-CiAgPGRhdGFzb3VyY2VzPgogICAgPGRhdGFzb3VyY2UgbmFtZT0nZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2onPgogICAgICA8Y29sdW1uIGRhdGF0eXBlPSdzdHJpbmcnIG5hbWU9J1s6TWVhc3VyZSBOYW1lc10nIHJvbGU9J2RpbWVuc2lvbicgdHlwZT0nbm9taW5hbCc-CiAgICAgICAgPGFsaWFzZXM-CiAgICAgICAgICA8YWxpYXMga2V5PScmcXVvdDtbZmVkZXJhdGVkLjEwbm5rOGQxdmdtdzhxMTd5dTc2dTA2cG5iY2pdLltjdGQ6Q3VzdG9tZXIgTmFtZTpxa10mcXVvdDsnIHZhbHVlPSdDb3VudCBvZiBDdXN0b21lcnMnIC8-CiAgICAgICAgPC9hbGlhc2VzPgogICAgICA8L2NvbHVtbj4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tPcmRlciBEYXRlXScgZGVyaXZhdGlvbj0nTm9uZScgbmFtZT0nW25vbmU6T3JkZXIgRGF0ZTpxa10nIHBpdm90PSdrZXknIHR5cGU9J3F1YW50aXRhdGl2ZScgLz4KICAgICAgPGNvbHVtbi1pbnN0YW5jZSBjb2x1bW49J1tSZWdpb25dJyBkZXJpdmF0aW9uPSdOb25lJyBuYW1lPSdbbm9uZTpSZWdpb246bmtdJyBwaXZvdD0na2V5JyB0eXBlPSdub21pbmFsJyAvPgogICAgICA8Y29sdW1uLWluc3RhbmNlIGNvbHVtbj0nW09yZGVyIERhdGVdJyBkZXJpdmF0aW9uPSdZZWFyJyBuYW1lPSdbeXI6T3JkZXIgRGF0ZTpva10nIHBpdm90PSdrZXknIHR5cGU9J29yZGluYWwnIC8-CiAgICA8L2RhdGFzb3VyY2U-CiAgPC9kYXRhc291cmNlcz4KICA8d29ya3NoZWV0IG5hbWU9J1doYXQgSWYgRm9yZWNhc3QnPgogICAgPHRhYmxlIC8-CiAgPC93b3Jrc2hlZXQ-CjwvY3VzdG9taXplZC12aWV3Pgo=" + } +] \ No newline at end of file diff --git a/test/assets/flow_populate_permissions.xml b/test/assets/flow_populate_permissions.xml index 59fe5bd6..ce3a22f9 100644 --- a/test/assets/flow_populate_permissions.xml +++ b/test/assets/flow_populate_permissions.xml @@ -11,5 +11,12 @@ + + + + + + + - \ No newline at end of file + diff --git a/test/assets/group_add_users.xml b/test/assets/group_add_users.xml new file mode 100644 index 00000000..23fd7bd9 --- /dev/null +++ b/test/assets/group_add_users.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/assets/groupsets_create.xml b/test/assets/groupsets_create.xml new file mode 100644 index 00000000..233b0f93 --- /dev/null +++ b/test/assets/groupsets_create.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/assets/groupsets_get.xml b/test/assets/groupsets_get.xml new file mode 100644 index 00000000..ff3bec1f --- /dev/null +++ b/test/assets/groupsets_get.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/test/assets/groupsets_get_by_id.xml b/test/assets/groupsets_get_by_id.xml new file mode 100644 index 00000000..558e4d87 --- /dev/null +++ b/test/assets/groupsets_get_by_id.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/groupsets_update.xml b/test/assets/groupsets_update.xml new file mode 100644 index 00000000..b64fa6ea --- /dev/null +++ b/test/assets/groupsets_update.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/assets/linked_tasks_get.xml b/test/assets/linked_tasks_get.xml new file mode 100644 index 00000000..23b7bbbb --- /dev/null +++ b/test/assets/linked_tasks_get.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/test/assets/linked_tasks_run_now.xml b/test/assets/linked_tasks_run_now.xml new file mode 100644 index 00000000..63cef73b --- /dev/null +++ b/test/assets/linked_tasks_run_now.xml @@ -0,0 +1,7 @@ + + + + diff --git a/test/assets/schedule_get_monthly_id_2.xml b/test/assets/schedule_get_monthly_id_2.xml new file mode 100644 index 00000000..ca84297e --- /dev/null +++ b/test/assets/schedule_get_monthly_id_2.xml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/virtual_connection_add_permissions.xml b/test/assets/virtual_connection_add_permissions.xml new file mode 100644 index 00000000..d8b05284 --- /dev/null +++ b/test/assets/virtual_connection_add_permissions.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/virtual_connection_database_connection_update.xml b/test/assets/virtual_connection_database_connection_update.xml new file mode 100644 index 00000000..a6135d60 --- /dev/null +++ b/test/assets/virtual_connection_database_connection_update.xml @@ -0,0 +1,6 @@ + + + + diff --git a/test/assets/virtual_connection_populate_connections.xml b/test/assets/virtual_connection_populate_connections.xml new file mode 100644 index 00000000..77d89952 --- /dev/null +++ b/test/assets/virtual_connection_populate_connections.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/assets/virtual_connections_download.xml b/test/assets/virtual_connections_download.xml new file mode 100644 index 00000000..889e70ce --- /dev/null +++ b/test/assets/virtual_connections_download.xml @@ -0,0 +1,7 @@ + + + + + {"policyCollection":{"luid":"34ae5eb9-ceac-4158-86f1-a5d8163d5261","policies":[]},"revision":{"luid":"1b2e2aae-b904-4f5a-aa4d-9f114b8e5f57","revisableProperties":{}}} + + diff --git a/test/assets/virtual_connections_get.xml b/test/assets/virtual_connections_get.xml new file mode 100644 index 00000000..f1f410e4 --- /dev/null +++ b/test/assets/virtual_connections_get.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/test/assets/virtual_connections_publish.xml b/test/assets/virtual_connections_publish.xml new file mode 100644 index 00000000..889e70ce --- /dev/null +++ b/test/assets/virtual_connections_publish.xml @@ -0,0 +1,7 @@ + + + + + {"policyCollection":{"luid":"34ae5eb9-ceac-4158-86f1-a5d8163d5261","policies":[]},"revision":{"luid":"1b2e2aae-b904-4f5a-aa4d-9f114b8e5f57","revisableProperties":{}}} + + diff --git a/test/assets/virtual_connections_revisions.xml b/test/assets/virtual_connections_revisions.xml new file mode 100644 index 00000000..37411342 --- /dev/null +++ b/test/assets/virtual_connections_revisions.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/test/assets/virtual_connections_update.xml b/test/assets/virtual_connections_update.xml new file mode 100644 index 00000000..60d5d169 --- /dev/null +++ b/test/assets/virtual_connections_update.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/test/test_custom_view.py b/test/test_custom_view.py index 55dec5df..80800c86 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -1,23 +1,32 @@ +from contextlib import ExitStack +import io import os +from pathlib import Path +from tempfile import TemporaryDirectory import unittest import requests_mock import tableauserverclient as TSC +from tableauserverclient.config import BYTES_PER_MB from tableauserverclient.datetime_helpers import format_datetime +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).parent / "assets" GET_XML = os.path.join(TEST_ASSET_DIR, "custom_view_get.xml") 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_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" class CustomViewTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test", False) - self.server.version = "3.19" # custom views only introduced in 3.19 + self.server.version = "3.21" # custom views only introduced in 3.19 # Fake sign in self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -132,3 +141,108 @@ def test_update(self) -> None: def test_update_missing_id(self) -> None: cv = TSC.CustomViewItem(name="test") self.assertRaises(TSC.MissingRequiredFieldError, self.server.custom_views.update, cv) + + def test_download(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._id = "1f951daf-4061-451a-9df1-69a8062664f2" + content = CUSTOM_VIEW_DOWNLOAD.read_bytes() + data = io.BytesIO() + with requests_mock.mock() as m: + m.get(f"{self.server.custom_views.expurl}/1f951daf-4061-451a-9df1-69a8062664f2/content", content=content) + self.server.custom_views.download(cv, data) + + assert data.getvalue() == content + + def test_publish_filepath(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + view = self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + def test_publish_file_str(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + view = self.server.custom_views.publish(cv, str(CUSTOM_VIEW_DOWNLOAD)) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + def test_publish_file_io(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + data = io.BytesIO(CUSTOM_VIEW_DOWNLOAD.read_bytes()) + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + view = self.server.custom_views.publish(cv, data) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None + + def test_publish_missing_owner_id(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + with self.assertRaises(ValueError): + self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) + + def test_publish_missing_wb_id(self) -> None: + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + with requests_mock.mock() as m: + m.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + with self.assertRaises(ValueError): + self.server.custom_views.publish(cv, CUSTOM_VIEW_DOWNLOAD) + + def test_large_publish(self): + cv = TSC.CustomViewItem(name="test") + cv._owner = TSC.UserItem() + cv._owner._id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + cv._workbook = TSC.WorkbookItem() + cv._workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + with ExitStack() as stack: + temp_dir = stack.enter_context(TemporaryDirectory()) + file_path = Path(temp_dir) / "test_file" + file_path.write_bytes(os.urandom(65 * BYTES_PER_MB)) + mock = stack.enter_context(requests_mock.mock()) + # Mock initializing upload + mock.post(self.server.fileuploads.baseurl, status_code=201, text=FILE_UPLOAD_INIT.read_text()) + # Mock the upload + mock.put( + f"{self.server.fileuploads.baseurl}/7720:170fe6b1c1c7422dadff20f944d58a52-1:0", + text=FILE_UPLOAD_APPEND.read_text(), + ) + # Mock the publish + mock.post(self.server.custom_views.expurl, status_code=201, text=Path(GET_XML).read_text()) + + view = self.server.custom_views.publish(cv, file_path) + + assert view is not None + assert isinstance(view, TSC.CustomViewItem) + assert view.id is not None + assert view.name is not None diff --git a/test/test_fileuploads.py b/test/test_fileuploads.py index cf0861e2..50a5ef48 100644 --- a/test/test_fileuploads.py +++ b/test/test_fileuploads.py @@ -1,8 +1,11 @@ +import contextlib +import io import os import unittest import requests_mock +from tableauserverclient.config import BYTES_PER_MB, config from tableauserverclient.server import Server from ._utils import asset @@ -11,6 +14,17 @@ FILEUPLOAD_APPEND = os.path.join(TEST_ASSET_DIR, "fileupload_append.xml") +@contextlib.contextmanager +def set_env(**environ): + old_environ = dict(os.environ) + os.environ.update(environ) + try: + yield + finally: + os.environ.clear() + os.environ.update(old_environ) + + class FileuploadsTests(unittest.TestCase): def setUp(self): self.server = Server("http://test", False) @@ -62,3 +76,14 @@ def test_upload_chunks_file_object(self): actual = self.server.fileuploads.upload(file_content) self.assertEqual(upload_id, actual) + + def test_upload_chunks_config(self): + data = io.BytesIO() + data.write(b"1" * (config.CHUNK_SIZE_MB * BYTES_PER_MB + 1)) + data.seek(0) + with set_env(TSC_CHUNK_SIZE_MB="1"): + chunker = self.server.fileuploads._read_chunks(data) + chunk = next(chunker) + assert len(chunk) == config.CHUNK_SIZE_MB * BYTES_PER_MB + data.seek(0) + assert len(chunk) < len(data.read()) diff --git a/test/test_flow.py b/test/test_flow.py index a90b1817..d458bc77 100644 --- a/test/test_flow.py +++ b/test/test_flow.py @@ -142,6 +142,16 @@ def test_populate_permissions(self) -> None: }, ) + self.assertEqual(permissions[1].grantee.tag_name, "groupSet") + self.assertEqual(permissions[1].grantee.id, "7ea95a1b-6872-44d6-a969-68598a7df4a0") + self.assertDictEqual( + permissions[1].capabilities, + { + TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Read: TSC.Permission.Mode.Allow, + }, + ) + def test_publish(self) -> None: with open(PUBLISH_XML, "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_group.py b/test/test_group.py index 1edc5055..fc9c75a6 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -14,6 +14,7 @@ POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml") ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml") +ADD_USERS = TEST_ASSET_DIR / "group_add_users.xml" ADD_USER_POPULATE = os.path.join(TEST_ASSET_DIR, "group_users_added.xml") CREATE_GROUP = os.path.join(TEST_ASSET_DIR, "group_create.xml") CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, "group_create_ad.xml") @@ -123,6 +124,54 @@ def test_add_user(self) -> None: self.assertEqual("testuser", user.name) self.assertEqual("ServerAdministrator", user.site_role) + def test_add_users(self) -> None: + self.server.version = "3.21" + self.baseurl = self.server.groups.baseurl + + def make_user(id: str, name: str, siteRole: str) -> TSC.UserItem: + user = TSC.UserItem(name, siteRole) + user._id = id + return user + + users = [ + make_user(id="5de011f8-4aa9-4d5b-b991-f464c8dd6bb7", name="Alice", siteRole="ServerAdministrator"), + make_user(id="5de011f8-3aa9-4d5b-b991-f467c8dd6bb8", name="Bob", siteRole="Explorer"), + make_user(id="5de011f8-2aa9-4d5b-b991-f466c8dd6bb8", name="Charlie", siteRole="Viewer"), + ] + group = TSC.GroupItem("test") + group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/{group.id}/users", text=ADD_USERS.read_text()) + resp_users = self.server.groups.add_users(group, users) + + for user, resp_user in zip(users, resp_users): + with self.subTest(user=user, resp_user=resp_user): + assert user.id == resp_user.id + assert user.name == resp_user.name + assert user.site_role == resp_user.site_role + + def test_remove_users(self) -> None: + self.server.version = "3.21" + self.baseurl = self.server.groups.baseurl + + def make_user(id: str, name: str, siteRole: str) -> TSC.UserItem: + user = TSC.UserItem(name, siteRole) + user._id = id + return user + + users = [ + make_user(id="5de011f8-4aa9-4d5b-b991-f464c8dd6bb7", name="Alice", siteRole="ServerAdministrator"), + make_user(id="5de011f8-3aa9-4d5b-b991-f467c8dd6bb8", name="Bob", siteRole="Explorer"), + make_user(id="5de011f8-2aa9-4d5b-b991-f466c8dd6bb8", name="Charlie", siteRole="Viewer"), + ] + group = TSC.GroupItem("test") + group._id = "e7833b48-c6f7-47b5-a2a7-36e7dd232758" + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{group.id}/users/remove") + self.server.groups.remove_users(group, users) + def test_add_user_before_populating(self) -> None: with open(GET_XML, "rb") as f: get_xml_response = f.read().decode("utf-8") diff --git a/test/test_groupsets.py b/test/test_groupsets.py new file mode 100644 index 00000000..5479809d --- /dev/null +++ b/test/test_groupsets.py @@ -0,0 +1,139 @@ +from pathlib import Path +import unittest + +from defusedxml.ElementTree import fromstring +import requests_mock + +import tableauserverclient as TSC +from tableauserverclient.models.reference_item import ResourceReference + +TEST_ASSET_DIR = Path(__file__).parent / "assets" +GROUPSET_CREATE = TEST_ASSET_DIR / "groupsets_create.xml" +GROUPSETS_GET = TEST_ASSET_DIR / "groupsets_get.xml" +GROUPSET_GET_BY_ID = TEST_ASSET_DIR / "groupsets_get_by_id.xml" +GROUPSET_UPDATE = TEST_ASSET_DIR / "groupsets_get_by_id.xml" + + +class TestGroupSets(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + self.server.version = "3.22" + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.group_sets.baseurl + + def test_get(self) -> None: + with requests_mock.mock() as m: + m.get(self.baseurl, text=GROUPSETS_GET.read_text()) + groupsets, pagination_item = self.server.group_sets.get() + + assert len(groupsets) == 3 + assert pagination_item.total_available == 3 + assert groupsets[0].id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupsets[0].name == "All Users" + assert groupsets[0].group_count == 1 + assert groupsets[0].groups[0].name == "group-one" + assert groupsets[0].groups[0].id == "gs-1" + + assert groupsets[1].id == "9a8a7b6b-5c4c-3d2d-1e0e-9a8a7b6b5b4b" + assert groupsets[1].name == "active-directory-group-import" + assert groupsets[1].group_count == 1 + assert groupsets[1].groups[0].name == "group-two" + assert groupsets[1].groups[0].id == "gs21" + + assert groupsets[2].id == "7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" + assert groupsets[2].name == "local-group-license-on-login" + assert groupsets[2].group_count == 1 + assert groupsets[2].groups[0].name == "group-three" + assert groupsets[2].groups[0].id == "gs-3" + + def test_get_by_id(self) -> None: + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", text=GROUPSET_GET_BY_ID.read_text()) + groupset = self.server.group_sets.get_by_id("1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d") + + assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupset.name == "All Users" + assert groupset.group_count == 3 + assert len(groupset.groups) == 3 + + assert groupset.groups[0].name == "group-one" + assert groupset.groups[0].id == "gs-1" + assert groupset.groups[1].name == "group-two" + assert groupset.groups[1].id == "gs21" + assert groupset.groups[2].name == "group-three" + assert groupset.groups[2].id == "gs-3" + + def test_update(self) -> None: + id_ = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + groupset = TSC.GroupSetItem("All Users") + groupset.id = id_ + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{id_}", text=GROUPSET_UPDATE.read_text()) + groupset = self.server.group_sets.update(groupset) + + assert groupset.id == id_ + assert groupset.name == "All Users" + assert groupset.group_count == 3 + assert len(groupset.groups) == 3 + + assert groupset.groups[0].name == "group-one" + assert groupset.groups[0].id == "gs-1" + assert groupset.groups[1].name == "group-two" + assert groupset.groups[1].id == "gs21" + assert groupset.groups[2].name == "group-three" + assert groupset.groups[2].id == "gs-3" + + def test_create(self) -> None: + groupset = TSC.GroupSetItem("All Users") + with requests_mock.mock() as m: + m.post(self.baseurl, text=GROUPSET_CREATE.read_text()) + groupset = self.server.group_sets.create(groupset) + + assert groupset.id == "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + assert groupset.name == "All Users" + assert groupset.group_count == 0 + assert len(groupset.groups) == 0 + + def test_add_group(self) -> None: + groupset = TSC.GroupSetItem("All") + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + group = TSC.GroupItem("Example") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{groupset.id}/groups/{group._id}") + self.server.group_sets.add_group(groupset, group) + + history = m.request_history + + assert len(history) == 1 + assert history[0].method == "PUT" + assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}" + + def test_remove_group(self) -> None: + groupset = TSC.GroupSetItem("All") + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + group = TSC.GroupItem("Example") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + + with requests_mock.mock() as m: + m.delete(f"{self.baseurl}/{groupset.id}/groups/{group._id}") + self.server.group_sets.remove_group(groupset, group) + + history = m.request_history + + assert len(history) == 1 + assert history[0].method == "DELETE" + assert history[0].url == f"{self.baseurl}/{groupset.id}/groups/{group._id}" + + def test_as_reference(self) -> None: + groupset = TSC.GroupSetItem() + groupset.id = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + ref = groupset.as_reference(groupset.id) + assert ref.id == groupset.id + assert ref.tag_name == groupset.tag_name + assert isinstance(ref, ResourceReference) diff --git a/test/test_job.py b/test/test_job.py index 83edadae..d8639708 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -120,3 +120,27 @@ def test_get_job_workbook_id(self) -> None: m.get(f"{self.baseurl}/{job_id}", text=response_xml) job = self.server.jobs.get_by_id(job_id) self.assertEqual(job.workbook_id, "5998aaaf-1abe-4d38-b4d9-bc53e85bdd13") + + def test_get_job_workbook_name(self) -> None: + response_xml = read_xml_asset(GET_BY_ID_WORKBOOK) + job_id = "bb1aab79-db54-4e96-9dd3-461d8f081d08" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.get_by_id(job_id) + self.assertEqual(job.workbook_name, "Superstore") + + def test_get_job_datasource_name(self) -> None: + response_xml = read_xml_asset(GET_BY_ID_FAILED_XML) + job_id = "777bf7c4-421d-4b2c-a518-11b90187c545" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{job_id}", text=response_xml) + job = self.server.jobs.get_by_id(job_id) + self.assertEqual(job.datasource_name, "World Indicators") + + def test_background_job_str(self) -> None: + job = TSC.BackgroundJobItem( + "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", datetime.now(), 1, "extractRefresh", "Failed" + ) + assert not str(job).startswith("< None: + self.server = TSC.Server("http://test", False) + self.server.version = "3.15" + + # Fake signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.linked_tasks.baseurl + + def test_parse_linked_task_flow_run(self): + xml = fromstring(GET_LINKED_TASKS.read_bytes()) + task_runs = LinkedTaskFlowRunItem._parse_element(xml, self.server.namespace) + assert 1 == len(task_runs) + task = task_runs[0] + assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73" + assert task.flow_run_priority == 1 + assert task.flow_run_consecutive_failed_count == 3 + assert task.flow_run_task_type == "runFlow" + assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673" + assert task.flow_name == "flow-name" + + def test_parse_linked_task_step(self): + xml = fromstring(GET_LINKED_TASKS.read_bytes()) + steps = LinkedTaskStepItem.from_task_xml(xml, self.server.namespace) + assert 1 == len(steps) + step = steps[0] + assert step.id == "f554a4df-bb6f-4294-94ee-9a709ef9bda0" + assert step.stop_downstream_on_failure + assert step.step_number == 1 + assert 1 == len(step.task_details) + task = step.task_details[0] + assert task.flow_run_id == "e3d1fc25-5644-4e32-af35-58dcbd1dbd73" + assert task.flow_run_priority == 1 + assert task.flow_run_consecutive_failed_count == 3 + assert task.flow_run_task_type == "runFlow" + assert task.flow_id == "ab1231eb-b8ca-461e-a131-83f3c2b6a673" + assert task.flow_name == "flow-name" + + def test_parse_linked_task(self): + tasks = LinkedTaskItem.from_response(GET_LINKED_TASKS.read_bytes(), self.server.namespace) + assert 1 == len(tasks) + task = tasks[0] + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_get_linked_tasks(self): + with requests_mock.mock() as m: + m.get(self.baseurl, text=GET_LINKED_TASKS.read_text()) + tasks, pagination_item = self.server.linked_tasks.get() + + assert 1 == len(tasks) + task = tasks[0] + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_get_by_id_str_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text()) + task = self.server.linked_tasks.get_by_id(id_) + + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_get_by_id_obj_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + in_task = LinkedTaskItem() + in_task.id = id_ + + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{id_}", text=GET_LINKED_TASKS.read_text()) + task = self.server.linked_tasks.get_by_id(in_task) + + assert task.id == "1b8211dc-51a8-45ce-a831-b5921708e03e" + assert task.num_steps == 1 + assert task.schedule is not None + assert task.schedule.id == "be077332-d01d-481b-b2f3-917e463d4dca" + + def test_run_now_str_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text()) + job = self.server.linked_tasks.run_now(id_) + + assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8" + assert job.status == "InProgress" + assert job.created_at == parse_datetime("2022-02-15T00:22:22Z") + assert job.linked_task_id == id_ + + def test_run_now_obj_linked_task(self): + id_ = "1b8211dc-51a8-45ce-a831-b5921708e03e" + in_task = LinkedTaskItem() + in_task.id = id_ + + with requests_mock.mock() as m: + m.post(f"{self.baseurl}/{id_}/runNow", text=RUN_LINKED_TASK_NOW.read_text()) + job = self.server.linked_tasks.run_now(in_task) + + assert job.id == "269a1e5a-1220-4a13-ac01-704982693dd8" + assert job.status == "InProgress" + assert job.created_at == parse_datetime("2022-02-15T00:22:22Z") + assert job.linked_task_id == id_ diff --git a/test/test_pager.py b/test/test_pager.py index b60559b2..c3035280 100644 --- a/test/test_pager.py +++ b/test/test_pager.py @@ -1,17 +1,31 @@ +import contextlib import os import unittest import requests_mock import tableauserverclient as TSC +from tableauserverclient.config import config TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +GET_VIEW_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") GET_XML_PAGE1 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_1.xml") GET_XML_PAGE2 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_2.xml") GET_XML_PAGE3 = os.path.join(TEST_ASSET_DIR, "workbook_get_page_3.xml") +@contextlib.contextmanager +def set_env(**environ): + old_environ = dict(os.environ) + os.environ.update(environ) + try: + yield + finally: + os.environ.clear() + os.environ.update(old_environ) + + class PagerTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test", False) @@ -22,7 +36,7 @@ def setUp(self): self.baseurl = self.server.workbooks.baseurl - def test_pager_with_no_options(self): + def test_pager_with_no_options(self) -> None: with open(GET_XML_PAGE1, "rb") as f: page_1 = f.read().decode("utf-8") with open(GET_XML_PAGE2, "rb") as f: @@ -48,7 +62,7 @@ def test_pager_with_no_options(self): self.assertEqual(wb2.name, "Page2Workbook") self.assertEqual(wb3.name, "Page3Workbook") - def test_pager_with_options(self): + def test_pager_with_options(self) -> None: with open(GET_XML_PAGE1, "rb") as f: page_1 = f.read().decode("utf-8") with open(GET_XML_PAGE2, "rb") as f: @@ -88,3 +102,23 @@ def test_pager_with_options(self): # Should have the last workbook wb3 = workbooks.pop() self.assertEqual(wb3.name, "Page3Workbook") + + def test_pager_with_env_var(self) -> None: + with set_env(TSC_PAGE_SIZE="1000"): + assert config.PAGE_SIZE == 1000 + loop = TSC.Pager(self.server.workbooks) + assert loop._options.pagesize == 1000 + + def test_queryset_with_env_var(self) -> None: + with set_env(TSC_PAGE_SIZE="1000"): + assert config.PAGE_SIZE == 1000 + loop = self.server.workbooks.all() + assert loop.request_options.pagesize == 1000 + + def test_pager_view(self) -> None: + with open(GET_VIEW_XML, "rb") as f: + view_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.server.views.baseurl, text=view_xml) + for view in TSC.Pager(self.server.views): + assert view.name is not None diff --git a/test/test_schedule.py b/test/test_schedule.py index 3bbf5709..0377295d 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -14,6 +14,7 @@ GET_HOURLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_hourly_id.xml") GET_DAILY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_daily_id.xml") GET_MONTHLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id.xml") +GET_MONTHLY_ID_2_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id_2.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml") CREATE_HOURLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_hourly.xml") CREATE_DAILY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_daily.xml") @@ -158,6 +159,21 @@ def test_get_monthly_by_id(self) -> None: self.assertEqual("Active", schedule.state) self.assertEqual(("1", "2"), schedule.interval_item.interval) + def test_get_monthly_by_id_2(self) -> None: + self.server.version = "3.15" + with open(GET_MONTHLY_ID_2_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + schedule_id = "8c5caf33-6223-4724-83c3-ccdc1e730a07" + baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id) + m.get(baseurl, text=response_xml) + schedule = self.server.schedules.get_by_id(schedule_id) + self.assertIsNotNone(schedule) + self.assertEqual(schedule_id, schedule.id) + self.assertEqual("Monthly First Monday!", schedule.name) + self.assertEqual("Active", schedule.state) + self.assertEqual(("Monday", "First"), schedule.interval_item.interval) + def test_delete(self) -> None: with requests_mock.mock() as m: m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) diff --git a/test/test_tagging.py b/test/test_tagging.py new file mode 100644 index 00000000..0184af41 --- /dev/null +++ b/test/test_tagging.py @@ -0,0 +1,230 @@ +from contextlib import ExitStack +import re +from typing import Iterable +import uuid +from xml.etree import ElementTree as ET + +import pytest +import requests_mock +import tableauserverclient as TSC + + +@pytest.fixture +def get_server() -> TSC.Server: + server = TSC.Server("http://test", False) + + # Fake sign in + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + server.version = "3.28" + return server + + +def add_tag_xml_response_factory(tags: Iterable[str]) -> str: + root = ET.Element("tsResponse") + tags_element = ET.SubElement(root, "tags") + for tag in tags: + tag_element = ET.SubElement(tags_element, "tag") + tag_element.attrib["label"] = tag + root.attrib["xmlns"] = "http://tableau.com/api" + return ET.tostring(root, encoding="utf-8").decode("utf-8") + + +def batch_add_tags_xml_response_factory(tags, content): + root = ET.Element("tsResponse") + tag_batch = ET.SubElement(root, "tagBatch") + tags_element = ET.SubElement(tag_batch, "tags") + for tag in tags: + tag_element = ET.SubElement(tags_element, "tag") + tag_element.attrib["label"] = tag + contents_element = ET.SubElement(tag_batch, "contents") + for item in content: + content_elem = ET.SubElement(contents_element, "content") + content_elem.attrib["id"] = item.id or "some_id" + t = item.__class__.__name__.replace("Item", "") or "" + content_elem.attrib["contentType"] = t + root.attrib["xmlns"] = "http://tableau.com/api" + return ET.tostring(root, encoding="utf-8").decode("utf-8") + + +def make_workbook() -> TSC.WorkbookItem: + workbook = TSC.WorkbookItem("project", "test") + workbook._id = str(uuid.uuid4()) + return workbook + + +def make_view() -> TSC.ViewItem: + view = TSC.ViewItem() + view._id = str(uuid.uuid4()) + return view + + +def make_datasource() -> TSC.DatasourceItem: + datasource = TSC.DatasourceItem("project", "test") + datasource._id = str(uuid.uuid4()) + return datasource + + +def make_table() -> TSC.TableItem: + table = TSC.TableItem("project", "test") + table._id = str(uuid.uuid4()) + return table + + +def make_database() -> TSC.DatabaseItem: + database = TSC.DatabaseItem("project", "test") + database._id = str(uuid.uuid4()) + return database + + +def make_flow() -> TSC.FlowItem: + flow = TSC.FlowItem("project", "test") + flow._id = str(uuid.uuid4()) + return flow + + +def make_vconn() -> TSC.VirtualConnectionItem: + vconn = TSC.VirtualConnectionItem("test") + vconn._id = str(uuid.uuid4()) + return vconn + + +sample_taggable_items = ( + [ + ("workbooks", make_workbook()), + ("workbooks", "some_id"), + ("views", make_view()), + ("views", "some_id"), + ("datasources", make_datasource()), + ("datasources", "some_id"), + ("tables", make_table()), + ("tables", "some_id"), + ("databases", make_database()), + ("databases", "some_id"), + ("flows", make_flow()), + ("flows", "some_id"), + ("virtual_connections", make_vconn()), + ("virtual_connections", "some_id"), + ], +) + +sample_tags = [ + "a", + ["a", "b"], + ["a", "b", "c", "c"], +] + + +@pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) +@pytest.mark.parametrize("tags", sample_tags) +def test_add_tags(get_server, endpoint_type, item, tags) -> None: + add_tags_xml = add_tag_xml_response_factory(tags) + endpoint = getattr(get_server, endpoint_type) + id_ = getattr(item, "id", item) + + with requests_mock.mock() as m: + m.put( + f"{endpoint.baseurl}/{id_}/tags", + status_code=200, + text=add_tags_xml, + ) + tag_result = endpoint.add_tags(item, tags) + + if isinstance(tags, str): + tags = [tags] + assert set(tag_result) == set(tags) + + +@pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) +@pytest.mark.parametrize("tags", sample_tags) +def test_delete_tags(get_server, endpoint_type, item, tags) -> None: + add_tags_xml = add_tag_xml_response_factory(tags) + endpoint = getattr(get_server, endpoint_type) + id_ = getattr(item, "id", item) + + if isinstance(tags, str): + tags = [tags] + tag_paths = "|".join(tags) + tag_paths = f"({tag_paths})" + matcher = re.compile(rf"{endpoint.baseurl}\/{id_}\/tags\/{tag_paths}") + with requests_mock.mock() as m: + m.delete( + matcher, + status_code=200, + text=add_tags_xml, + ) + endpoint.delete_tags(item, tags) + history = m.request_history + + tag_set = set(tags) + assert len(history) == len(tag_set) + urls = {r.url.split("/")[-1] for r in history} + assert urls == tag_set + + +@pytest.mark.parametrize("endpoint_type, item", *sample_taggable_items) +@pytest.mark.parametrize("tags", sample_tags) +def test_update_tags(get_server, endpoint_type, item, tags) -> None: + endpoint = getattr(get_server, endpoint_type) + id_ = getattr(item, "id", item) + tags = set([tags] if isinstance(tags, str) else tags) + with ExitStack() as stack: + if isinstance(item, str): + stack.enter_context(pytest.raises((ValueError, NotImplementedError))) + elif hasattr(item, "_initial_tags"): + initial_tags = set(["x", "y", "z"]) + item._initial_tags = initial_tags + add_tags_xml = add_tag_xml_response_factory(tags - initial_tags) + delete_tags_xml = add_tag_xml_response_factory(initial_tags - tags) + m = stack.enter_context(requests_mock.mock()) + m.put( + f"{endpoint.baseurl}/{id_}/tags", + status_code=200, + text=add_tags_xml, + ) + + tag_paths = "|".join(initial_tags - tags) + tag_paths = f"({tag_paths})" + matcher = re.compile(rf"{endpoint.baseurl}\/{id_}\/tags\/{tag_paths}") + m.delete( + matcher, + status_code=200, + text=delete_tags_xml, + ) + + else: + stack.enter_context(pytest.raises(NotImplementedError)) + + endpoint.update_tags(item) + + +def test_tags_batch_add(get_server) -> None: + server = get_server + content = [make_workbook(), make_view(), make_datasource(), make_table(), make_database()] + tags = ["a", "b"] + add_tags_xml = batch_add_tags_xml_response_factory(tags, content) + with requests_mock.mock() as m: + m.put( + f"{server.tags.baseurl}:batchCreate", + status_code=200, + text=add_tags_xml, + ) + tag_result = server.tags.batch_add(tags, content) + + assert set(tag_result) == set(tags) + + +def test_tags_batch_delete(get_server) -> None: + server = get_server + content = [make_workbook(), make_view(), make_datasource(), make_table(), make_database()] + tags = ["a", "b"] + add_tags_xml = batch_add_tags_xml_response_factory(tags, content) + with requests_mock.mock() as m: + m.put( + f"{server.tags.baseurl}:batchDelete", + status_code=200, + text=add_tags_xml, + ) + tag_result = server.tags.batch_delete(tags, content) + + assert set(tag_result) == set(tags) diff --git a/test/test_virtual_connection.py b/test/test_virtual_connection.py new file mode 100644 index 00000000..975033d2 --- /dev/null +++ b/test/test_virtual_connection.py @@ -0,0 +1,242 @@ +import json +from pathlib import Path +import unittest + +import requests_mock + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem + +ASSET_DIR = Path(__file__).parent / "assets" + +VIRTUAL_CONNECTION_GET_XML = ASSET_DIR / "virtual_connections_get.xml" +VIRTUAL_CONNECTION_POPULATE_CONNECTIONS = ASSET_DIR / "virtual_connection_populate_connections.xml" +VC_DB_CONN_UPDATE = ASSET_DIR / "virtual_connection_database_connection_update.xml" +VIRTUAL_CONNECTION_DOWNLOAD = ASSET_DIR / "virtual_connections_download.xml" +VIRTUAL_CONNECTION_UPDATE = ASSET_DIR / "virtual_connections_update.xml" +VIRTUAL_CONNECTION_REVISIONS = ASSET_DIR / "virtual_connections_revisions.xml" +VIRTUAL_CONNECTION_PUBLISH = ASSET_DIR / "virtual_connections_publish.xml" +ADD_PERMISSIONS = ASSET_DIR / "virtual_connection_add_permissions.xml" + + +class TestVirtualConnections(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test") + + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + self.server.version = "3.23" + + self.baseurl = f"{self.server.baseurl}/sites/{self.server.site_id}/virtualConnections" + return super().setUp() + + def test_from_xml(self): + items = VirtualConnectionItem.from_response(VIRTUAL_CONNECTION_GET_XML.read_bytes(), self.server.namespace) + + assert len(items) == 1 + virtual_connection = items[0] + assert virtual_connection.created_at == parse_datetime("2024-05-30T09:00:00Z") + assert not virtual_connection.has_extracts + assert virtual_connection.id == "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + assert virtual_connection.is_certified + assert virtual_connection.name == "vconn" + assert virtual_connection.updated_at == parse_datetime("2024-06-18T09:00:00Z") + assert virtual_connection.webpage_url == "https://test/#/site/site-name/virtualconnections/3" + + def test_virtual_connection_get(self): + with requests_mock.mock() as m: + m.get(self.baseurl, text=VIRTUAL_CONNECTION_GET_XML.read_text()) + items, pagination_item = self.server.virtual_connections.get() + + assert len(items) == 1 + assert pagination_item.total_available == 1 + assert items[0].name == "vconn" + + def test_virtual_connection_populate_connections(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}/connections", text=VIRTUAL_CONNECTION_POPULATE_CONNECTIONS.read_text()) + vc_out = self.server.virtual_connections.populate_connections(vconn) + connection_list = list(vconn.connections) + + assert vc_out is vconn + assert vc_out._connections is not None + + assert len(connection_list) == 1 + connection = connection_list[0] + assert connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + assert connection.connection_type == "postgres" + assert connection.server_address == "localhost" + assert connection.server_port == "5432" + assert connection.username == "pgadmin" + + def test_virtual_connection_update_connection_db_connection(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + connection = TSC.ConnectionItem() + connection._id = "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + connection.server_address = "localhost" + connection.server_port = "5432" + connection.username = "pgadmin" + connection.password = "password" + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{vconn.id}/connections/{connection.id}/modify", text=VC_DB_CONN_UPDATE.read_text()) + updated_connection = self.server.virtual_connections.update_connection_db_connection(vconn, connection) + + assert updated_connection.id == "37ca6ced-58d7-4dcf-99dc-f0a85223cbef" + assert updated_connection.server_address == "localhost" + assert updated_connection.server_port == "5432" + assert updated_connection.username == "pgadmin" + assert updated_connection.password is None + + def test_virtual_connection_get_by_id(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}", text=VIRTUAL_CONNECTION_DOWNLOAD.read_text()) + vconn = self.server.virtual_connections.get_by_id(vconn) + + assert vconn.content + assert vconn.created_at is None + assert vconn.id is None + assert "policyCollection" in vconn.content + assert "revision" in vconn.content + + def test_virtual_connection_update(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + vconn.is_certified = True + vconn.certification_note = "demo certification note" + vconn.project_id = "5286d663-8668-4ac2-8c8d-91af7d585f6b" + vconn.owner_id = "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{vconn.id}", text=VIRTUAL_CONNECTION_UPDATE.read_text()) + vconn = self.server.virtual_connections.update(vconn) + + assert not vconn.has_extracts + assert vconn.id is None + assert vconn.is_certified + assert vconn.name == "testv1" + assert vconn.certification_note == "demo certification note" + assert vconn.project_id == "5286d663-8668-4ac2-8c8d-91af7d585f6b" + assert vconn.owner_id == "9324cf6b-ba72-4b8e-b895-ac3f28d2f0e0" + + def test_virtual_connection_get_revisions(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}/revisions", text=VIRTUAL_CONNECTION_REVISIONS.read_text()) + revisions, pagination_item = self.server.virtual_connections.get_revisions(vconn) + + assert len(revisions) == 3 + assert pagination_item.total_available == 3 + assert revisions[0].resource_id == vconn.id + assert revisions[0].resource_name == vconn.name + assert revisions[0].created_at == parse_datetime("2016-07-26T20:34:56Z") + assert revisions[0].revision_number == "1" + assert not revisions[0].current + assert not revisions[0].deleted + assert revisions[0].user_name == "Cassie" + assert revisions[0].user_id == "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + assert revisions[1].resource_id == vconn.id + assert revisions[1].resource_name == vconn.name + assert revisions[1].created_at == parse_datetime("2016-07-27T20:34:56Z") + assert revisions[1].revision_number == "2" + assert not revisions[1].current + assert not revisions[1].deleted + assert revisions[2].resource_id == vconn.id + assert revisions[2].resource_name == vconn.name + assert revisions[2].created_at == parse_datetime("2016-07-28T20:34:56Z") + assert revisions[2].revision_number == "3" + assert revisions[2].current + assert not revisions[2].deleted + assert revisions[2].user_name == "Cassie" + assert revisions[2].user_id == "5de011f8-5aa9-4d5b-b991-f462c8dd6bb7" + + def test_virtual_connection_download_revision(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/{vconn.id}/revisions/1", text=VIRTUAL_CONNECTION_DOWNLOAD.read_text()) + content = self.server.virtual_connections.download_revision(vconn, 1) + + assert content + assert "policyCollection" in content + data = json.loads(content) + assert "policyCollection" in data + assert "revision" in data + + def test_virtual_connection_delete(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + with requests_mock.mock() as m: + m.delete(f"{self.baseurl}/{vconn.id}") + self.server.virtual_connections.delete(vconn) + self.server.virtual_connections.delete(vconn.id) + + assert m.call_count == 2 + + def test_virtual_connection_publish(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + vconn.project_id = "9836791c-9468-40f0-b7f3-d10b9562a046" + vconn.owner_id = "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + with requests_mock.mock() as m: + m.post(f"{self.baseurl}?overwrite=false&publishAsDraft=false", text=VIRTUAL_CONNECTION_PUBLISH.read_text()) + vconn = self.server.virtual_connections.publish( + vconn, '{"test": 0}', mode="CreateNew", publish_as_draft=False + ) + + assert vconn.name == "vconn_test" + assert vconn.owner_id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert vconn.project_id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert vconn.content + assert "policyCollection" in vconn.content + assert "revision" in vconn.content + + def test_virtual_connection_publish_draft_overwrite(self): + vconn = VirtualConnectionItem("vconn") + vconn._id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + vconn.project_id = "9836791c-9468-40f0-b7f3-d10b9562a046" + vconn.owner_id = "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + with requests_mock.mock() as m: + m.post(f"{self.baseurl}?overwrite=true&publishAsDraft=true", text=VIRTUAL_CONNECTION_PUBLISH.read_text()) + vconn = self.server.virtual_connections.publish( + vconn, '{"test": 0}', mode="Overwrite", publish_as_draft=True + ) + + assert vconn.name == "vconn_test" + assert vconn.owner_id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9" + assert vconn.project_id == "9836791c-9468-40f0-b7f3-d10b9562a046" + assert vconn.content + assert "policyCollection" in vconn.content + assert "revision" in vconn.content + + def test_add_permissions(self) -> None: + with open(ADD_PERMISSIONS, "rb") as f: + response_xml = f.read().decode("utf-8") + + single_virtual_connection = TSC.VirtualConnectionItem("test") + single_virtual_connection._id = "21778de4-b7b9-44bc-a599-1506a2639ace" + + bob = TSC.UserItem.as_reference("7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + group_of_people = TSC.GroupItem.as_reference("5e5e1978-71fa-11e4-87dd-7382f5c437af") + + new_permissions = [ + TSC.PermissionsRule(bob, {"Write": "Allow"}), + TSC.PermissionsRule(group_of_people, {"Read": "Deny"}), + ] + + with requests_mock.mock() as m: + m.put(self.baseurl + "/21778de4-b7b9-44bc-a599-1506a2639ace/permissions", text=response_xml) + permissions = self.server.virtual_connections.add_permissions(single_virtual_connection, new_permissions) + + self.assertEqual(permissions[0].grantee.tag_name, "group") + self.assertEqual(permissions[0].grantee.id, "5e5e1978-71fa-11e4-87dd-7382f5c437af") + self.assertDictEqual(permissions[0].capabilities, {TSC.Permission.Capability.Read: TSC.Permission.Mode.Deny}) + + self.assertEqual(permissions[1].grantee.tag_name, "user") + self.assertEqual(permissions[1].grantee.id, "7c37ee24-c4b1-42b6-a154-eaeab7ee330a") + self.assertDictEqual(permissions[1].capabilities, {TSC.Permission.Capability.Write: TSC.Permission.Mode.Allow})