Repository: tableau/server-client-python

Issue:
`populate_connections` doesn't fetch `datasource_id`
**Describe the bug**

Hi,

I'm encountering an issue with the Tableau Python client. Specifically, the `datasource_id` is missing from the `ConnectionItem` object.

Here’s the scenario: after retrieving a datasource by id, I populate its connections, but the `ConnectionItem` objects lack the datasource information.

From reviewing the source code:
https://github.com/tableau/server-client-python/blob/1d98fdad189ebed130fb904e8fa5dca2207f9011/tableauserverclient/server/endpoint/datasources_endpoint.py#L106
it seems the REST method being used by the module is: `GET /api/api-version/sites/site-id/datasources/datasource-id/connections`.

According to the [official documentation](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_data_sources.htm), this method does not appear to return datasource information.

**Versions**
 - Tableau Cloud
 - Python version: 3.11.0
 - TSC library version: 0.34

**To Reproduce**

```python
import os
import tableauserverclient as TSC

token_name = 'my_token'
token_value = os.environ['TABLEAU_TOKEN']
tableau_site = 'my-site-it'
tableau_server = 'https://eu-west-1a.online.tableau.com'
datasource_id = 'REPLACE_ME'


def main():
    tableau_auth = TSC.PersonalAccessTokenAuth(token_name, token_value, site_id=tableau_site)
    server = TSC.Server(tableau_server, use_server_version=True)
    with server.auth.sign_in(tableau_auth):
        ds = server.datasources.get_by_id(datasource_id)
        server.datasources.populate_connections(ds)

        # This will fail
        assert ds.connections[0].datasource_id


if __name__ == '__main__':
    main()
```

**Results**

`connections[0].datasource_id` is `None`, data is not fetched from the REST API.

Thanks :) 

Discussion:

Patch:
diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py
index a0c0a98..5cafe46 100644
--- a/tableauserverclient/models/custom_view_item.py
+++ b/tableauserverclient/models/custom_view_item.py
@@ -5,14 +5,58 @@ from defusedxml.ElementTree import fromstring, tostring
 from typing import Callable, Optional
 from collections.abc import Iterator
 
-from .exceptions import UnpopulatedPropertyError
-from .user_item import UserItem
-from .view_item import ViewItem
-from .workbook_item import WorkbookItem
-from ..datetime_helpers import parse_datetime
+from tableauserverclient.models.exceptions import UnpopulatedPropertyError
+from tableauserverclient.models.user_item import UserItem
+from tableauserverclient.models.view_item import ViewItem
+from tableauserverclient.models.workbook_item import WorkbookItem
+from tableauserverclient.datetime_helpers import parse_datetime
 
 
 class CustomViewItem:
+    """
+    Represents a Custom View item on Tableau Server.
+
+    Parameters
+    ----------
+    id : Optional[str]
+        The ID of the Custom View item.
+
+    name : Optional[str]
+        The name of the Custom View item.
+
+    Attributes
+    ----------
+    content_url : Optional[str]
+        The content URL of the Custom View item.
+
+    created_at : Optional[datetime]
+        The date and time the Custom View item was created.
+
+    image: bytes
+        The image of the Custom View item. Must be populated first.
+
+    pdf: bytes
+        The PDF of the Custom View item. Must be populated first.
+
+    csv: Iterator[bytes]
+        The CSV of the Custom View item. Must be populated first.
+
+    shared : Optional[bool]
+        Whether the Custom View item is shared.
+
+    updated_at : Optional[datetime]
+        The date and time the Custom View item was last updated.
+
+    owner : Optional[UserItem]
+        The id of the owner of the Custom View item.
+
+    workbook : Optional[WorkbookItem]
+        The id of the workbook the Custom View item belongs to.
+
+    view : Optional[ViewItem]
+        The id of the view the Custom View item belongs to.
+    """
+
     def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None:
         self._content_url: Optional[str] = None  # ?
         self._created_at: Optional["datetime"] = None
diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py
index 4211bb7..35dfa5d 100644
--- a/tableauserverclient/server/endpoint/auth_endpoint.py
+++ b/tableauserverclient/server/endpoint/auth_endpoint.py
@@ -84,9 +84,10 @@ class Auth(Endpoint):
         self._check_status(server_response, url)
         parsed_response = fromstring(server_response.content)
         site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None)
+        site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None)
         user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None)
         auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None)
-        self.parent_srv._set_auth(site_id, user_id, auth_token)
+        self.parent_srv._set_auth(site_id, user_id, auth_token, site_url)
         logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}")
         return Auth.contextmgr(self.sign_out)
 
@@ -155,9 +156,10 @@ class Auth(Endpoint):
         self._check_status(server_response, url)
         parsed_response = fromstring(server_response.content)
         site_id = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("id", None)
+        site_url = parsed_response.find(".//t:site", namespaces=self.parent_srv.namespace).get("contentUrl", None)
         user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None)
         auth_token = parsed_response.find("t:credentials", namespaces=self.parent_srv.namespace).get("token", None)
-        self.parent_srv._set_auth(site_id, user_id, auth_token)
+        self.parent_srv._set_auth(site_id, user_id, auth_token, site_url)
         logger.info(f"Signed into {self.parent_srv.server_address} as user with id {user_id}")
         return Auth.contextmgr(self.sign_out)
 
diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py
index b02b05d..8d78dca 100644
--- a/tableauserverclient/server/endpoint/custom_views_endpoint.py
+++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py
@@ -3,7 +3,7 @@ import logging
 import os
 from contextlib import closing
 from pathlib import Path
-from typing import Optional, Union
+from typing import Optional, Union, TYPE_CHECKING
 from collections.abc import Iterator
 
 from tableauserverclient.config import BYTES_PER_MB, config
@@ -21,6 +21,9 @@ from tableauserverclient.server import (
 
 from tableauserverclient.helpers.logging import logger
 
+if TYPE_CHECKING:
+    from tableauserverclient.server.query import QuerySet
+
 """
 Get a list of custom views on a site
 get the details of a custom view
@@ -51,19 +54,31 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]):
     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.
-    If the filter parameters include ownerId: Users will see only custom views that they own.
-    If the filter parameters include viewId and/or workbookId, and don't include ownerId:
-        Users will see those custom views that they have Write and WebAuthoring permissions for.
-    If site user visibility is not set to Limited, the Users will see those custom views that are "public",
-     meaning the value of their shared attribute is true.
-    If site user visibility is set to Limited, ????
-    """
-
     @api(version="3.18")
     def get(self, req_options: Optional["RequestOptions"] = None) -> tuple[list[CustomViewItem], PaginationItem]:
+        """
+        Get a list of custom views on a site.
+
+        If the request has no filter parameters: Administrators will see all custom views.
+            Other users will see only custom views that they own.
+        If the filter parameters include ownerId: Users will see only custom views that they own.
+        If the filter parameters include viewId and/or workbookId, and don't include ownerId:
+            Users will see those custom views that they have Write and WebAuthoring permissions for.
+        If site user visibility is not set to Limited, the Users will see those custom views that are "public",
+         meaning the value of their shared attribute is true.
+        If site user visibility is set to Limited, ????
+
+        Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#list_custom_views
+
+        Parameters
+        ----------
+        req_options : RequestOptions, optional
+            Filtering options for the request, by default None
+
+        Returns
+        -------
+        tuple[list[CustomViewItem], PaginationItem]
+        """
         logger.info("Querying all custom views on site")
         url = self.baseurl
         server_response = self.get_request(url, req_options)
@@ -73,6 +88,19 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]):
 
     @api(version="3.18")
     def get_by_id(self, view_id: str) -> Optional[CustomViewItem]:
+        """
+        Get the details of a specific custom view.
+
+        Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_custom_view
+
+        Parameters
+        ----------
+        view_id : str
+
+        Returns
+        -------
+        Optional[CustomViewItem]
+        """
         if not view_id:
             error = "Custom view item missing ID."
             raise MissingRequiredFieldError(error)
@@ -83,6 +111,27 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]):
 
     @api(version="3.18")
     def populate_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None:
+        """
+        Populate the image of a custom view.
+
+        Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#get_custom_view_image
+
+        Parameters
+        ----------
+        view_item : CustomViewItem
+
+        req_options : ImageRequestOptions, optional
+            Options to customize the image returned, by default None
+
+        Returns
+        -------
+        None
+
+        Raises
+        ------
+        MissingRequiredFieldError
+            If the view_item is missing an ID
+        """
         if not view_item.id:
             error = "Custom View item missing ID."
             raise MissingRequiredFieldError(error)
@@ -101,6 +150,26 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]):
 
     @api(version="3.23")
     def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None:
+        """
+        Populate the PDF of a custom view.
+
+        Parameters
+        ----------
+        custom_view_item : CustomViewItem
+            The custom view item to populate the PDF for.
+
+        req_options : PDFRequestOptions, optional
+            Options to customize the PDF returned, by default None
+
+        Returns
+        -------
+        None
+
+        Raises
+        ------
+        MissingRequiredFieldError
+            If the custom view item is missing an ID
+        """
         if not custom_view_item.id:
             error = "Custom View item missing ID."
             raise MissingRequiredFieldError(error)
@@ -121,6 +190,26 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]):
 
     @api(version="3.23")
     def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None:
+        """
+        Populate the CSV of a custom view.
+
+        Parameters
+        ----------
+        custom_view_item : CustomViewItem
+            The custom view item to populate the CSV for.
+
+        req_options : CSVRequestOptions, optional
+            Options to customize the CSV returned, by default None
+
+        Returns
+        -------
+        None
+
+        Raises
+        ------
+        MissingRequiredFieldError
+            If the custom view item is missing an ID
+        """
         if not custom_view_item.id:
             error = "Custom View item missing ID."
             raise MissingRequiredFieldError(error)
@@ -141,6 +230,21 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]):
 
     @api(version="3.18")
     def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]:
+        """
+        Updates the name, owner, or shared status of a custom view.
+
+        Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#update_custom_view
+
+        Parameters
+        ----------
+        view_item : CustomViewItem
+            The custom view item to update.
+
+        Returns
+        -------
+        Optional[CustomViewItem]
+            The updated custom view item.
+        """
         if not view_item.id:
             error = "Custom view item missing ID."
             raise MissingRequiredFieldError(error)
@@ -158,6 +262,25 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]):
     # Delete 1 view by id
     @api(version="3.19")
     def delete(self, view_id: str) -> None:
+        """
+        Deletes a single custom view by ID.
+
+        Rest API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_workbooks_and_views.htm#delete_custom_view
+
+        Parameters
+        ----------
+        view_id : str
+            The ID of the custom view to delete.
+
+        Returns
+        -------
+        None
+
+        Raises
+        ------
+        ValueError
+            If the view_id is not provided.
+        """
         if not view_id:
             error = "Custom View ID undefined."
             raise ValueError(error)
@@ -167,6 +290,27 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]):
 
     @api(version="3.21")
     def download(self, view_item: CustomViewItem, file: PathOrFileW) -> PathOrFileW:
+        """
+        Download the definition of a custom view as json. The file parameter can
+        be a file path or a file object. If a file path is provided, the file
+        will be written to that location. If a file object is provided, the file
+        will be written to that object.
+
+        May contain sensitive information.
+
+        Parameters
+        ----------
+        view_item : CustomViewItem
+            The custom view item to download.
+
+        file : PathOrFileW
+            The file path or file object to write the custom view to.
+
+        Returns
+        -------
+        PathOrFileW
+            The file path or file object that the custom view was written to.
+        """
         url = f"{self.expurl}/{view_item.id}/content"
         server_response = self.get_request(url)
         if isinstance(file, io_types_w):
@@ -180,6 +324,25 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]):
 
     @api(version="3.21")
     def publish(self, view_item: CustomViewItem, file: PathOrFileR) -> Optional[CustomViewItem]:
+        """
+        Publish a custom view to Tableau Server. The file parameter can be a
+        file path or a file object. If a file path is provided, the file will be
+        read from that location. If a file object is provided, the file will be
+        read from that object.
+
+        Parameters
+        ----------
+        view_item : CustomViewItem
+            The custom view item to publish.
+
+        file : PathOrFileR
+            The file path or file object to read the custom view from.
+
+        Returns
+        -------
+        Optional[CustomViewItem]
+            The published custom view item.
+        """
         url = self.expurl
         if isinstance(file, io_types_r):
             size = get_file_object_size(file)
@@ -207,3 +370,25 @@ class CustomViews(QuerysetEndpoint[CustomViewItem]):
 
         server_response = self.post_request(url, xml_request, content_type)
         return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace)
+
+    def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> "QuerySet[CustomViewItem]":
+        """
+        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 <field_name>=<value>. If you want to use a different operator,
+        you can specify it through <field_name>__<operator>=<value>. Field
+        names can either be in snake_case or camelCase.
+
+        This endpoint supports the following fields and operators:
+
+        view_id=...
+        workbook_id=...
+        owner_id=...
+        """
+
+        return super().filter(*invalid, page_size=page_size, **kwargs)
diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py
index 6bd809c..88c739d 100644
--- a/tableauserverclient/server/endpoint/datasources_endpoint.py
+++ b/tableauserverclient/server/endpoint/datasources_endpoint.py
@@ -102,10 +102,15 @@ class Datasources(QuerysetEndpoint[DatasourceItem], TaggingMixin[DatasourceItem]
         datasource_item._set_connections(connections_fetcher)
         logger.info(f"Populated connections for datasource (ID: {datasource_item.id})")
 
-    def _get_datasource_connections(self, datasource_item, req_options=None):
+    def _get_datasource_connections(
+        self, datasource_item: DatasourceItem, req_options: Optional[RequestOptions] = None
+    ) -> list[ConnectionItem]:
         url = f"{self.baseurl}/{datasource_item.id}/connections"
         server_response = self.get_request(url, req_options)
         connections = ConnectionItem.from_response(server_response.content, self.parent_srv.namespace)
+        for connection in connections:
+            connection._datasource_id = datasource_item.id
+            connection._datasource_name = datasource_item.name
         return connections
 
     # Delete 1 datasource by id
diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py
index d79ac7f..2a5bb80 100644
--- a/tableauserverclient/server/request_options.py
+++ b/tableauserverclient/server/request_options.py
@@ -122,6 +122,7 @@ class RequestOptions(RequestOptionsBase):
         NotificationType = "notificationType"
         OwnerDomain = "ownerDomain"
         OwnerEmail = "ownerEmail"
+        OwnerId = "ownerId"
         OwnerName = "ownerName"
         ParentProjectId = "parentProjectId"
         Priority = "priority"
@@ -148,8 +149,10 @@ class RequestOptions(RequestOptionsBase):
         UpdatedAt = "updatedAt"
         UserCount = "userCount"
         UserId = "userId"
+        ViewId = "viewId"
         ViewUrlName = "viewUrlName"
         WorkbookDescription = "workbookDescription"
+        WorkbookId = "workbookId"
         WorkbookName = "workbookName"
 
     class Direction:
diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py
index 4eeefca..02abb3f 100644
--- a/tableauserverclient/server/server.py
+++ b/tableauserverclient/server/server.py
@@ -207,12 +207,14 @@ class Server:
         self._site_id = None
         self._user_id = None
         self._auth_token = None
+        self._site_url = None
         self._session = self._session_factory()
 
-    def _set_auth(self, site_id, user_id, auth_token):
+    def _set_auth(self, site_id, user_id, auth_token, site_url=None):
         self._site_id = site_id
         self._user_id = user_id
         self._auth_token = auth_token
+        self._site_url = site_url
 
     def _get_legacy_version(self):
         # the serverInfo call was introduced in 2.4, earlier than that we have this different call
@@ -282,6 +284,13 @@ class Server:
             raise NotSignedInError(error)
         return self._site_id
 
+    @property
+    def site_url(self):
+        if self._site_url is None:
+            error = "Missing site URL. You must sign in first."
+            raise NotSignedInError(error)
+        return self._site_url
+
     @property
     def user_id(self):
         if self._user_id is None:


Test patch:
diff --git a/test/test_auth.py b/test/test_auth.py
index 48100ad..09e3e25 100644
--- a/test/test_auth.py
+++ b/test/test_auth.py
@@ -27,6 +27,7 @@ class AuthTests(unittest.TestCase):
 
         self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token)
         self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id)
+        self.assertEqual("Samples", self.server.site_url)
         self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id)
 
     def test_sign_in_with_personal_access_tokens(self):
@@ -41,6 +42,7 @@ class AuthTests(unittest.TestCase):
 
         self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token)
         self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id)
+        self.assertEqual("Samples", self.server.site_url)
         self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id)
 
     def test_sign_in_impersonate(self):
@@ -93,6 +95,7 @@ class AuthTests(unittest.TestCase):
 
         self.assertIsNone(self.server._auth_token)
         self.assertIsNone(self.server._site_id)
+        self.assertIsNone(self.server._site_url)
         self.assertIsNone(self.server._user_id)
 
     def test_switch_site(self):
@@ -109,6 +112,7 @@ class AuthTests(unittest.TestCase):
 
         self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token)
         self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id)
+        self.assertEqual("Samples", self.server.site_url)
         self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id)
 
     def test_revoke_all_server_admin_tokens(self):
@@ -125,4 +129,5 @@ class AuthTests(unittest.TestCase):
 
         self.assertEqual("eIX6mvFsqyansa4KqEI1UwOpS8ggRs2l", self.server.auth_token)
         self.assertEqual("6b7179ba-b82b-4f0f-91ed-812074ac5da6", self.server.site_id)
+        self.assertEqual("Samples", self.server.site_url)
         self.assertEqual("1a96d216-e9b8-497b-a82a-0b899a965e01", self.server.user_id)
diff --git a/test/test_datasource.py b/test/test_datasource.py
index 45d9ba9..e8a9572 100644
--- a/test/test_datasource.py
+++ b/test/test_datasource.py
@@ -174,17 +174,22 @@ class DatasourceTests(unittest.TestCase):
             connections: Optional[list[ConnectionItem]] = single_datasource.connections
 
         self.assertIsNotNone(connections)
+        assert connections is not None
         ds1, ds2 = connections
         self.assertEqual("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", ds1.id)
         self.assertEqual("textscan", ds1.connection_type)
         self.assertEqual("forty-two.net", ds1.server_address)
         self.assertEqual("duo", ds1.username)
         self.assertEqual(True, ds1.embed_password)
+        self.assertEqual(ds1.datasource_id, single_datasource.id)
+        self.assertEqual(single_datasource.name, ds1.datasource_name)
         self.assertEqual("970e24bc-e200-4841-a3e9-66e7d122d77e", ds2.id)
         self.assertEqual("sqlserver", ds2.connection_type)
         self.assertEqual("database.com", ds2.server_address)
         self.assertEqual("heero", ds2.username)
         self.assertEqual(False, ds2.embed_password)
+        self.assertEqual(ds2.datasource_id, single_datasource.id)
+        self.assertEqual(single_datasource.name, ds2.datasource_name)
 
     def test_update_connection(self) -> None:
         populate_xml, response_xml = read_xml_assets(POPULATE_CONNECTIONS_XML, UPDATE_CONNECTION_XML)