diff --git a/samples/update_connection_auth.py b/samples/update_connection_auth.py new file mode 100644 index 000000000..661a5e275 --- /dev/null +++ b/samples/update_connection_auth.py @@ -0,0 +1,62 @@ +import argparse +import logging +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser( + description="Update a single connection on a datasource or workbook to embed credentials" + ) + + # Common options + parser.add_argument("--server", "-s", help="Server address", required=True) + parser.add_argument("--site", "-S", help="Site name", required=True) + parser.add_argument("--token-name", "-p", help="Personal access token name", required=True) + parser.add_argument("--token-value", "-v", help="Personal access token value", required=True) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="Logging level (default: error)", + ) + + # Resource and connection details + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id", help="Workbook or datasource ID") + parser.add_argument("connection_id", help="Connection ID to update") + parser.add_argument("datasource_username", help="Username to set for the connection") + parser.add_argument("datasource_password", help="Password to set for the connection") + parser.add_argument("authentication_type", help="Authentication type") + + args = parser.parse_args() + + # Logging setup + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + + with server.auth.sign_in(tableau_auth): + endpoint = {"workbook": server.workbooks, "datasource": server.datasources}.get(args.resource_type) + + update_function = endpoint.update_connection + resource = endpoint.get_by_id(args.resource_id) + endpoint.populate_connections(resource) + + connections = [conn for conn in resource.connections if conn.id == args.connection_id] + assert len(connections) == 1, f"Connection ID '{args.connection_id}' not found." + + connection = connections[0] + connection.username = args.datasource_username + connection.password = args.datasource_password + connection.authentication_type = args.authentication_type + connection.embed_password = True + + updated_connection = update_function(resource, connection) + print(f"Updated connection: {updated_connection.__dict__}") + + +if __name__ == "__main__": + main() diff --git a/samples/update_connections_auth.py b/samples/update_connections_auth.py new file mode 100644 index 000000000..6ae27e333 --- /dev/null +++ b/samples/update_connections_auth.py @@ -0,0 +1,64 @@ +import argparse +import logging +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description="Bulk update all workbook or datasource connections") + + # Common options + parser.add_argument("--server", "-s", help="Server address", required=True) + parser.add_argument("--site", "-S", help="Site name", required=True) + parser.add_argument("--token-name", "-p", help="Personal access token name", required=True) + parser.add_argument("--token-value", "-v", help="Personal access token value", required=True) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="Logging level (default: error)", + ) + + # Resource-specific + parser.add_argument("resource_type", choices=["workbook", "datasource"]) + parser.add_argument("resource_id") + parser.add_argument("datasource_username") + parser.add_argument("authentication_type") + parser.add_argument("--datasource_password", default=None, help="Datasource password (optional)") + parser.add_argument( + "--embed_password", default="true", choices=["true", "false"], help="Embed password (default: true)" + ) + + args = parser.parse_args() + + # Set logging level + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.TableauAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=True) + + with server.auth.sign_in(tableau_auth): + endpoint = {"workbook": server.workbooks, "datasource": server.datasources}.get(args.resource_type) + + resource = endpoint.get_by_id(args.resource_id) + endpoint.populate_connections(resource) + + connection_luids = [conn.id for conn in resource.connections] + embed_password = args.embed_password.lower() == "true" + + # Call unified update_connections method + connection_items = endpoint.update_connections( + resource, + connection_luids=connection_luids, + authentication_type=args.authentication_type, + username=args.datasource_username, + password=args.datasource_password, + embed_password=embed_password, + ) + + print(f"Updated connections on {args.resource_type} {args.resource_id}: {connection_items}") + + +if __name__ == "__main__": + main() diff --git a/tableauserverclient/models/connection_item.py b/tableauserverclient/models/connection_item.py index 6a8244fb1..3e8c6d290 100644 --- a/tableauserverclient/models/connection_item.py +++ b/tableauserverclient/models/connection_item.py @@ -41,6 +41,9 @@ class ConnectionItem: server_port: str The port used for the connection. + auth_type: str + Specifies the type of authentication used by the connection. + connection_credentials: ConnectionCredentials The Connection Credentials object containing authentication details for the connection. Replaces username/password/embed_password when @@ -59,6 +62,7 @@ def __init__(self): self.username: Optional[str] = None self.connection_credentials: Optional[ConnectionCredentials] = None self._query_tagging: Optional[bool] = None + self._auth_type: Optional[str] = None @property def datasource_id(self) -> Optional[str]: @@ -91,8 +95,16 @@ def query_tagging(self, value: Optional[bool]): return self._query_tagging = value + @property + def auth_type(self) -> Optional[str]: + return self._auth_type + + @auth_type.setter + def auth_type(self, value: Optional[str]): + self._auth_type = value + def __repr__(self): - return "".format( + return "".format( **self.__dict__ ) @@ -112,6 +124,7 @@ def from_response(cls, resp, ns) -> list["ConnectionItem"]: connection_item._query_tagging = ( string_to_bool(s) if (s := connection_xml.get("queryTagging", None)) else None ) + connection_item._auth_type = connection_xml.get("authenticationType", 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) @@ -139,6 +152,7 @@ def from_xml_element(cls, parsed_response, ns) -> list["ConnectionItem"]: connection_item.server_address = connection_xml.get("serverAddress", None) connection_item.server_port = connection_xml.get("serverPort", None) + connection_item._auth_type = connection_xml.get("authenticationType", None) connection_credentials = connection_xml.find(".//t:connectionCredentials", namespaces=ns) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 168446974..ba242c8ec 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -319,6 +319,61 @@ def update_connection( logger.info(f"Updated datasource item (ID: {datasource_item.id} & connection item {connection_item.id}") return connection + @api(version="3.26") + def update_connections( + self, + datasource_item: DatasourceItem, + connection_luids: Iterable[str], + authentication_type: str, + username: Optional[str] = None, + password: Optional[str] = None, + embed_password: Optional[bool] = None, + ) -> list[ConnectionItem]: + """ + Bulk updates one or more datasource connections by LUID. + + Parameters + ---------- + datasource_item : DatasourceItem + The datasource item containing the connections. + + connection_luids : Iterable of str + The connection LUIDs to update. + + authentication_type : str + The authentication type to use (e.g., 'auth-keypair'). + + username : str, optional + The username to set. + + password : str, optional + The password or secret to set. + + embed_password : bool, optional + Whether to embed the password. + + Returns + ------- + Iterable of str + The connection LUIDs that were updated. + """ + + url = f"{self.baseurl}/{datasource_item.id}/connections" + + request_body = RequestFactory.Datasource.update_connections_req( + connection_luids=connection_luids, + authentication_type=authentication_type, + username=username, + password=password, + embed_password=embed_password, + ) + server_response = self.put_request(url, request_body) + connection_items = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + updated_ids: list[str] = [conn.id for conn in connection_items] + + logger.info(f"Updated connections for datasource {datasource_item.id}: {', '.join(updated_ids)}") + return connection_items + @api(version="2.8") def refresh(self, datasource_item: DatasourceItem, incremental: bool = False) -> JobItem: """ diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index bf4088b9f..907d2d99e 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -325,6 +325,64 @@ def update_connection(self, workbook_item: WorkbookItem, connection_item: Connec logger.info(f"Updated workbook item (ID: {workbook_item.id} & connection item {connection_item.id})") return connection + # Update workbook_connections + @api(version="3.26") + def update_connections( + self, + workbook_item: WorkbookItem, + connection_luids: Iterable[str], + authentication_type: str, + username: Optional[str] = None, + password: Optional[str] = None, + embed_password: Optional[bool] = None, + ) -> list[ConnectionItem]: + """ + Bulk updates one or more workbook connections by LUID, including authenticationType, username, password, and embedPassword. + + Parameters + ---------- + workbook_item : WorkbookItem + The workbook item containing the connections. + + connection_luids : Iterable of str + The connection LUIDs to update. + + authentication_type : str + The authentication type to use (e.g., 'AD Service Principal'). + + username : str, optional + The username to set (e.g., client ID for keypair auth). + + password : str, optional + The password or secret to set. + + embed_password : bool, optional + Whether to embed the password. + + Returns + ------- + Iterable of str + The connection LUIDs that were updated. + """ + + url = f"{self.baseurl}/{workbook_item.id}/connections" + + request_body = RequestFactory.Workbook.update_connections_req( + connection_luids, + authentication_type, + username=username, + password=password, + embed_password=embed_password, + ) + + # Send request + server_response = self.put_request(url, request_body) + connection_items = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace) + updated_ids: list[str] = [conn.id for conn in connection_items] + + logger.info(f"Updated connections for workbook {workbook_item.id}: {', '.join(updated_ids)}") + return connection_items + # Download workbook contents with option of passing in filepath @api(version="2.0") @parameter_added_in(no_extract="2.5") diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c898004f7..45da66054 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -244,6 +244,32 @@ def publish_req_chunked(self, datasource_item, connection_credentials=None, conn parts = {"request_payload": ("", xml_request, "text/xml")} return _add_multipart(parts) + @_tsrequest_wrapped + def update_connections_req( + self, + element: ET.Element, + connection_luids: Iterable[str], + authentication_type: str, + username: Optional[str] = None, + password: Optional[str] = None, + embed_password: Optional[bool] = None, + ): + conn_luids_elem = ET.SubElement(element, "connectionLUIDs") + for luid in connection_luids: + ET.SubElement(conn_luids_elem, "connectionLUID").text = luid + + connection_elem = ET.SubElement(element, "connection") + connection_elem.set("authenticationType", authentication_type) + + if username is not None: + connection_elem.set("userName", username) + + if password is not None: + connection_elem.set("password", password) + + if embed_password is not None: + connection_elem.set("embedPassword", str(embed_password).lower()) + class DQWRequest: def add_req(self, dqw_item): @@ -1092,6 +1118,32 @@ def embedded_extract_req( if (id_ := datasource_item.id) is not None: datasource_element.attrib["id"] = id_ + @_tsrequest_wrapped + def update_connections_req( + self, + element: ET.Element, + connection_luids: Iterable[str], + authentication_type: str, + username: Optional[str] = None, + password: Optional[str] = None, + embed_password: Optional[bool] = None, + ): + conn_luids_elem = ET.SubElement(element, "connectionLUIDs") + for luid in connection_luids: + ET.SubElement(conn_luids_elem, "connectionLUID").text = luid + + connection_elem = ET.SubElement(element, "connection") + connection_elem.set("authenticationType", authentication_type) + + if username is not None: + connection_elem.set("userName", username) + + if password is not None: + connection_elem.set("password", password) + + if embed_password is not None: + connection_elem.set("embedPassword", str(embed_password).lower()) + class Connection: @_tsrequest_wrapped diff --git a/test/assets/datasource_connections_update.xml b/test/assets/datasource_connections_update.xml new file mode 100644 index 000000000..d726aad25 --- /dev/null +++ b/test/assets/datasource_connections_update.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/test/assets/workbook_update_connections.xml b/test/assets/workbook_update_connections.xml new file mode 100644 index 000000000..ce6ca227f --- /dev/null +++ b/test/assets/workbook_update_connections.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/test/test_datasource.py b/test/test_datasource.py index a604ba8b0..5e7e91358 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -30,6 +30,7 @@ UPDATE_XML = "datasource_update.xml" UPDATE_HYPER_DATA_XML = "datasource_data_update.xml" UPDATE_CONNECTION_XML = "datasource_connection_update.xml" +UPDATE_CONNECTIONS_XML = "datasource_connections_update.xml" class DatasourceTests(unittest.TestCase): @@ -217,6 +218,44 @@ def test_update_connection(self) -> None: self.assertEqual("9876", new_connection.server_port) self.assertEqual("foo", new_connection.username) + def test_update_connections(self) -> None: + populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTIONS_XML) + + with requests_mock.Mocker() as m: + + datasource_id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + connection_luids = ["be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", "a1b2c3d4-e5f6-7a8b-9c0d-123456789abc"] + + datasource = TSC.DatasourceItem(datasource_id) + datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" + datasource.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + self.server.version = "3.26" + + url = f"{self.server.baseurl}/{datasource.id}/connections" + m.get( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", + text=populate_xml, + ) + m.put( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/datasources/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/connections", + text=response_xml, + ) + + print("BASEURL:", self.server.baseurl) + print("Calling PUT on:", f"{self.server.baseurl}/{datasource.id}/connections") + + connection_items = self.server.datasources.update_connections( + datasource_item=datasource, + connection_luids=connection_luids, + authentication_type="auth-keypair", + username="testuser", + password="testpass", + embed_password=True, + ) + updated_ids = [conn.id for conn in connection_items] + + self.assertEqual(updated_ids, connection_luids) + def test_populate_permissions(self) -> None: with open(asset(POPULATE_PERMISSIONS_XML), "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_workbook.py b/test/test_workbook.py index 84afd7fcb..f6c494f96 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -14,7 +14,7 @@ from tableauserverclient.models import UserItem, GroupItem, PermissionsRule from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError from tableauserverclient.server.request_factory import RequestFactory -from ._utils import asset +from ._utils import read_xml_asset, read_xml_assets, asset TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -39,6 +39,7 @@ REVISION_XML = os.path.join(TEST_ASSET_DIR, "workbook_revision.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update.xml") UPDATE_PERMISSIONS = os.path.join(TEST_ASSET_DIR, "workbook_update_permissions.xml") +UPDATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_connections.xml") class WorkbookTests(unittest.TestCase): @@ -980,6 +981,38 @@ def test_odata_connection(self) -> None: assert xml_connection is not None self.assertEqual(xml_connection.get("serverAddress"), url) + def test_update_workbook_connections(self) -> None: + populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTIONS_XML) + + with requests_mock.Mocker() as m: + workbook_id = "1a2b3c4d-5e6f-7a8b-9c0d-112233445566" + connection_luids = ["abc12345-def6-7890-gh12-ijklmnopqrst", "1234abcd-5678-efgh-ijkl-0987654321mn"] + + workbook = TSC.WorkbookItem(workbook_id) + workbook._id = workbook_id + self.server.version = "3.26" + url = f"{self.server.baseurl}/{workbook_id}/connections" + m.get( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", + text=populate_xml, + ) + m.put( + "http://test/api/3.26/sites/dad65087-b08b-4603-af4e-2887b8aafc67/workbooks/1a2b3c4d-5e6f-7a8b-9c0d-112233445566/connections", + text=response_xml, + ) + + connection_items = self.server.workbooks.update_connections( + workbook_item=workbook, + connection_luids=connection_luids, + authentication_type="AD Service Principal", + username="svc-client", + password="secret-token", + embed_password=True, + ) + updated_ids = [conn.id for conn in connection_items] + + self.assertEqual(updated_ids, connection_luids) + def test_get_workbook_all_fields(self) -> None: self.server.version = "3.21" baseurl = self.server.workbooks.baseurl