10000 feat: virtual connections by jorwoods · Pull Request #1429 · tableau/server-client-python · GitHub
[go: up one dir, main page]

Skip to content

feat: virtual connections #1429

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Sep 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 imp 8000 ort 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
67ED
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
Loading
0