8000 feat: virtual connections · LehmD/server-client-python@fad98bd · GitHub
[go: up one dir, main page]

Skip to content

Commit fad98bd

Browse files
committed
feat: virtual connections
Merge pull request tableau#1429 from jorwoods/jorwoods/virtual_connections
1 parent 6053bdc commit fad98bd

19 files changed

+664
-9
lines changed

tableauserverclient/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
TaskItem,
4444
UserItem,
4545
ViewItem,
46+
VirtualConnectionItem,
4647
WebhookItem,
4748
WeeklyInterval,
4849
WorkbookItem,
@@ -124,4 +125,5 @@
124125
"LinkedTaskItem",
125126
"LinkedTaskStepItem",
126127
"LinkedTaskFlowRunItem",
128+
"VirtualConnectionItem",
127129
]

tableauserverclient/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from tableauserverclient.models.task_item import TaskItem
4646
from tableauserverclient.models.user_item import UserItem
4747
from tableauserverclient.models.view_item import ViewItem
48+
from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem
4849
from tableauserverclient.models.webhook_item import WebhookItem
4950
from tableauserverclient.models.workbook_item import WorkbookItem
5051

@@ -96,6 +97,7 @@
9697
"TaskItem",
9798
"UserItem",
9899
"ViewItem",
100+
"VirtualConnectionItem",
99101
"WebhookItem",
100102
"WorkbookItem",
101103
"LinkedTaskItem",

tableauserverclient/models/connection_item.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,14 @@ def from_response(cls, resp, ns) -> List["ConnectionItem"]:
6666
for connection_xml in all_connection_xml:
6767
connection_item = cls()
6868
connection_item._id = connection_xml.get("id", None)
69-
connection_item._connection_type = connection_xml.get("type", None)
69+
connection_item._connection_type = connection_xml.get("type", connection_xml.get("dbClass", None))
7070
connection_item.embed_password = string_to_bool(connection_xml.get("embedPassword", ""))
7171
connection_item.server_address = connection_xml.get("serverAddress", None)
7272
connection_item.server_port = connection_xml.get("serverPort", None)
7373
connection_item.username = connection_xml.get("userName", None)
74-
connection_item._query_tagging = string_to_bool(connection_xml.get("queryTaggingEnabled", None))
74+
connection_item._query_tagging = (
75+
string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None
76+
)
7577
datasource_elem = connection_xml.find(".//t:datasource", namespaces=ns)
7678
if datasource_elem is not None:
7779
connection_item._datasource_id = datasource_elem.get("id", None)

tableauserverclient/models/tableau_types.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from typing import Union
22

3-
from .datasource_item import DatasourceItem
4-
from .flow_item import FlowItem
5-
from .project_item import ProjectItem
6-
from .view_item import ViewItem
7-
from .workbook_item import WorkbookItem
8-
from .metric_item import MetricItem
3+
from tableauserverclient.models.datasource_item import DatasourceItem
4+
from tableauserverclient.models.flow_item import FlowItem
5+
from tableauserverclient.models.project_item import ProjectItem
6+
from tableauserverclient.models.view_item import ViewItem
7+
from tableauserverclient.models.workbook_item import WorkbookItem
8+
from tableauserverclient.models.metric_item import MetricItem
9+
from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem
910

1011

1112
class Resource:
@@ -18,12 +19,13 @@ class Resource:
1819
Metric = "metric"
1920
Project = "project"
2021
View = "view"
22+
VirtualConnection = "virtualConnection"
2123
Workbook = "workbook"
2224

2325

2426
# resource types that have permissions, can be renamed, etc
2527
# todo: refactoring: should actually define TableauItem as an interface and let all these implement it
26-
TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem]
28+
TableauItem = Union[DatasourceItem, FlowItem, MetricItem, ProjectItem, ViewItem, WorkbookItem, VirtualConnectionItem]
2729

2830

2931
def plural_type(content_type: Resource) -> str:
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import datetime as dt
2+
import json
3+
from typing import Callable, Dict, Iterable, List, Optional
4+
from xml.etree.ElementTree import Element
5+
6+
from defusedxml.ElementTree import fromstring
7+
8+
from tableauserverclient.datetime_helpers import parse_datetime
9+
from tableauserverclient.models.connection_item import ConnectionItem
10+
from tableauserverclient.models.exceptions import UnpopulatedPropertyError
11+
from tableauserverclient.models.permissions_item import PermissionsRule
12+
13+
14+
class VirtualConnectionItem:
15+
def __init__(self, name: str) -> None:
16+
self.name = name
17+
self.created_at: Optional[dt.datetime] = None
18+
self.has_extracts: Optional[bool] = None
19+
self._id: Optional[str] = None
20+
self.is_certified: Optional[bool] = None
21+
self.updated_at: Optional[dt.datetime] = None
22+
self.webpage_url: Optional[str] = None
23+
self._connections: Optional[Callable[[], Iterable[ConnectionItem]]] = None
24+
self.project_id: Optional[str] = None
25+
self.owner_id: Optional[str] = None
26+
self.content: Optional[Dict[str, dict]] = None
27+
self.certification_note: Optional[str] = None
28+
29+
def __str__(self) -> str:
30+
return f"{self.__class__.__qualname__}(name={self.name})"
31+
32+
def __repr__(self) -> str:
33+
return f"<{self!s}>"
34+
35+
def _set_permissions(self, permissions):
36+
self._permissions = permissions
37+
38+
@property
39+
def id(self) -> Optional[str]:
40+
return self._id
41+
42+
@property
43+
def permissions(self) -> List[PermissionsRule]:
44+
if self._permissions is None:
45+
error = "Workbook item must be populated with permissions first."
46+
raise UnpopulatedPropertyError(error)
47+
return self._permissions()
48+
49+
@property
50+
def connections(self) -> Iterable[ConnectionItem]:
51+
if self._connections is None:
52+
raise AttributeError("connections not populated. Call populate_connections() first.")
53+
return self._connections()
54+
55+
@classmethod
56+
def from_response(cls, response: bytes, ns: Dict[str, str]) -> List["VirtualConnectionItem"]:
57+
parsed_response = fromstring(response)
58+
return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:virtualConnection[@name]", ns)]
59+
60+
@classmethod
61+
def from_xml(cls, xml: Element, ns: Dict[str, str]) -> "VirtualConnectionItem":
62+
v_conn = cls(xml.get("name", ""))
63+
v_conn._id = xml.get("id", None)
64+
v_conn.webpage_url = xml.get("webpageUrl", None)
65+
v_conn.created_at = parse_datetime(xml.get("createdAt", None))
66+
v_conn.updated_at = parse_datetime(xml.get("updatedAt", None))
67+
v_conn.is_certified = string_to_bool(s) if (s := xml.get("isCertified", None)) else None
68+
v_conn.certification_note = xml.get("certificationNote", None)
69+
v_conn.has_extracts = string_to_bool(s) if (s := xml.get("hasExtracts", None)) else None
70+
v_conn.project_id = p.get("id", None) if ((p := xml.find(".//t:project[@id]", ns)) is not None) else None
71+
v_conn.owner_id = o.get("id", None) if ((o := xml.find(".//t:owner[@id]", ns)) is not None) else None
72+
v_conn.content = json.loads(c.text or "{}") if ((c := xml.find(".//t:content", ns)) is not None) else None
73+
return v_conn
74+
75+
76+
def string_to_bool(s: str) -> bool:
77+
return s.lower() in ["true", "1", "t", "y", "yes"]

tableauserverclient/server/endpoint/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from tableauserverclient.server.endpoint.tasks_endpoint import Tasks
2828
from tableauserverclient.server.endpoint.users_endpoint import Users
2929
from tableauserverclient.server.endpoint.views_endpoint import Views
30+
from tableauserverclient.server.endpoint.virtual_connections_endpoint import VirtualConnections
3031
from tableauserverclient.server.endpoint.webhooks_endpoint import Webhooks
3132
from tableauserverclient.server.endpoint.workbooks_endpoint import Workbooks
3233

@@ -62,6 +63,7 @@
6263
"Tasks",
6364
"Users",
6465
"Views",
66+
"VirtualConnections",
6567
"Webhooks",
6668
"Workbooks",
6769
]
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
from functools import partial
2+
import json
3+
from pathlib import Path
4+
from typing import Iterable, List, Optional, Set, TYPE_CHECKING, Tuple, Union
5+
6+
from tableauserverclient.models.connection_item import ConnectionItem
7+
from tableauserverclient.models.pagination_item import PaginationItem
8+
from tableauserverclient.models.revision_item import RevisionItem
9+
from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem
10+
from tableauserverclient.server.request_factory import RequestFactory
11+
from tableauserverclient.server.request_options import RequestOptions
12+
from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
13+
from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint
14+
from tableauserverclient.server.endpoint.resource_tagger import TaggingMixin
15+
from tableauserverclient.server.pager import Pager
16+
17+
if TYPE_CHECKING:
18+
from tableauserverclient.server import Server
19+
20+
21+
class VirtualConnections(QuerysetEndpoint[VirtualConnectionItem], TaggingMixin):
22+
def __init__(self, parent_srv: "Server") -> None:
23+
super().__init__(parent_srv)
24+
self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl)
25+
26+
@property
27+
def baseurl(self) -> str:
28+
return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/virtualConnections"
29+
30+
@api(version="3.18")
31+
def get(self, req_options: Optional[RequestOptions] = None) -> Tuple[List[VirtualConnectionItem], PaginationItem]:
32+
server_response = self.get_request(self.baseurl, req_options)
33+
pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
34+
virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)
35+
return virtual_connections, pagination_item
36+
37+
@api(version="3.18")
38+
def populate_connections(self, virtual_connection: VirtualConnectionItem) -> VirtualConnectionItem:
39+
def _connection_fetcher():
40+
return Pager(partial(self._get_virtual_database_connections, virtual_connection))
41+
42+
virtual_connection._connections = _connection_fetcher
43+
return virtual_connection
44+
45+
def _get_virtual_database_connections(
46+
self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None
47+
) -> Tuple[List[ConnectionItem], PaginationItem]:
48+
server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/connections", req_options)
49+
connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)
50+
pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
51+
52+
return connections, pagination_item
53+
54+
@api(version="3.18")
55+
def update_connection_db_connection(
56+
self, virtual_connection: Union[str, VirtualConnectionItem], connection: ConnectionItem
57+
) -> ConnectionItem:
58+
vconn_id = getattr(virtual_connection, "id", virtual_connection)
59+
url = f"{self.baseurl}/{vconn_id}/connections/{connection.id}/modify"
60+
xml_request = RequestFactory.VirtualConnection.update_db_connection(connection)
61+
server_response = self.put_request(url, xml_request)
62+
return ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
63+
64+
@api(version="3.23")
65+
def get_by_id(self, virtual_connection: Union[str, VirtualConnectionItem]) -> VirtualConnectionItem:
66+
vconn_id = getattr(virtual_connection, "id", virtual_connection)
67+
url = f"{self.baseurl}/{vconn_id}"
68+
server_response = self.get_request(url)
69+
return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
70+
71+
@api(version="3.23")
72+
def download(self, virtual_connection: Union[str, VirtualConnectionItem]) -> str:
73+
v_conn = self.get_by_id(virtual_connection)
74+
return json.dumps(v_conn.content)
75+
76+
@api(version="3.23")
77+
def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnectionItem:
78+
url = f"{self.baseurl}/{virtual_connection.id}"
79+
xml_request = RequestFactory.VirtualConnection.update(virtual_connection)
80+
server_response = self.put_request(url, xml_request)
81+
return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
82+
83+
@api(version="3.23")
84+
def get_revisions(
85+
self, virtual_connection: VirtualConnectionItem, req_options: Optional[RequestOptions] = None
86+
) -> Tuple[List[RevisionItem], PaginationItem]:
87+
server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options)
88+
pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
89+
revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection)
90+
return revisions, pagination_item
91+
92+
@api(version="3.23")
93+
def download_revision(self, virtual_connection: VirtualConnectionItem, revision_number: int) -> str:
94+
url = f"{self.baseurl}/{virtual_connection.id}/revisions/{revision_number}"
95+
server_response = self.get_request(url)
96+
virtual_connection = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
97+
return json.dumps(virtual_connection.content)
98+
99+
@api(version="3.23")
100+
def delete(self, virtual_connection: Union[VirtualConnectionItem, str]) -> None:
101+
vconn_id = getattr(virtual_connection, "id", virtual_connection)
102+
self.delete_request(f"{self.baseurl}/{vconn_id}")
103+
104+
@api(version="3.23")
105+
def publish(
106+
self,
107+
virtual_connection: VirtualConnectionItem,
108+
virtual_connection_content: str,
109+
mode: str = "CreateNew",
110+
publish_as_draft: bool = False,
111+
) -> VirtualConnectionItem:
112+
"""
113+
Publish a virtual connection to the server.
114+
115+
For the virtual_connection object, name, project_id, and owner_id are
116+
required.
117+
118+
The virtual_connection_content can be a json string or a file path to a
119+
json file.
120+
121+
The mode can be "CreateNew" or "Overwrite". If mode is
122+
"Overwrite" and the virtual connection already exists, it will be
123+
overwritten.
124+
125+
If publish_as_draft is True, the virtual connection will be published
126+
as a draft, and the id of the draft will be on the response object.
127+
"""
128+
try:
129+
json.loads(virtual_connection_content)
130+
except json.JSONDecodeError:
131+
file = Path(virtual_connection_content)
132+
if not file.exists():
133+
raise RuntimeError(f"{virtual_connection_content} is not valid json nor an existing file path")
134+
content = file.read_text()
135+
else:
136+
content = virtual_connection_content
137+
138+
if mode not in ["CreateNew", "Overwrite"]:
139+
raise ValueError(f"Invalid mode: {mode}")
140+
overwrite = mode == "Overwrite"
141+
142+
url = f"{self.baseurl}?overwrite={str(overwrite).lower()}&publishAsDraft={str(publish_as_draft).lower()}"
143+
xml_request = RequestFactory.VirtualConnection.publish(virtual_connection, content)
144+
server_response = self.post_request(url, xml_request)
145+
return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
146+
147+
@api(version="3.22")
148+
def populate_permissions(self, item: VirtualConnectionItem) -> None:
149+
self._permissions.populate(item)
150+
151+
@api(version="3.22")
152+
def add_permissions(self, resource, rules):
153+
return self._permissions.update(resource, rules)
154+
155+
@api(version="3.22")
156+
def delete_permission(self, item, capability_item):
157+
return self._permissions.delete(item, capability_item)
158+
159+
@api(version="3.23")
160+
def add_tags(
161+
self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str]
162+
) -> Set[str]:
163+
return super().add_tags(virtual_connection, tags)
164+
165+
@api(version="3.23")
166+
def delete_tags(
167+
self, virtual_connection: Union[VirtualConnectionItem, str], tags: Union[Iterable[str], str]
168+
) -> None:
169+
return super().delete_tags(virtual_connection, tags)
170+
171+
@api(version="3.23")
172+
def update_tags(self, virtual_connection: VirtualConnectionItem) -> None:
173+
raise NotImplementedError("Update tags is not implemented for Virtual Connections")

0 commit comments

Comments
 (0)
0