Skip to content

Commit

Permalink
feat: virtual connections
Browse files Browse the repository at this point in the history
Merge pull request tableau#1429 from jorwoods/jorwoods/virtual_connections
  • Loading branch information
jacalata committed Sep 2, 2024
1 parent 6053bdc commit fad98bd
Show file tree
Hide file tree
Showing 19 changed files with 664 additions and 9 deletions.
2 changes: 2 additions & 0 deletions tableauserverclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
TaskItem,
UserItem,
ViewItem,
VirtualConnectionItem,
WebhookItem,
WeeklyInterval,
WorkbookItem,
Expand Down Expand Up @@ -124,4 +125,5 @@
"LinkedTaskItem",
"LinkedTaskStepItem",
"LinkedTaskFlowRunItem",
"VirtualConnectionItem",
]
2 changes: 2 additions & 0 deletions tableauserverclient/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,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

Expand Down Expand Up @@ -96,6 +97,7 @@
"TaskItem",
"UserItem",
"ViewItem",
"VirtualConnectionItem",
"WebhookItem",
"WorkbookItem",
"LinkedTaskItem",
Expand Down
6 changes: 4 additions & 2 deletions tableauserverclient/models/connection_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 9 additions & 7 deletions tableauserverclient/models/tableau_types.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand Down
77 changes: 77 additions & 0 deletions tableauserverclient/models/virtual_connection_item.py
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 2 additions & 0 deletions tableauserverclient/server/endpoint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
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

Expand Down Expand Up @@ -62,6 +63,7 @@
"Tasks",
"Users",
"Views",
"VirtualConnections",
"Webhooks",
"Workbooks",
]
173 changes: 173 additions & 0 deletions tableauserverclient/server/endpoint/virtual_connections_endpoint.py
Original file line number Diff line number Diff line change
@@ -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")
Loading

0 comments on commit fad98bd

Please sign in to comment.