Skip to content
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

feat: users csv import #1409

Open
wants to merge 18 commits into
base: development
Choose a base branch
from
Open
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
286 changes: 281 additions & 5 deletions tableauserverclient/server/endpoint/users_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
from collections.abc import Iterable
import copy
import csv
import io
import itertools
import logging
from typing import Optional
import warnings

from tableauserverclient.server.query import QuerySet

from .endpoint import QuerysetEndpoint, api
from .exceptions import MissingRequiredFieldError, ServerResponseError
from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError, ServerResponseError
from tableauserverclient.server import RequestFactory, RequestOptions
from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem
from ..pager import Pager
from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem, JobItem
from tableauserverclient.server.pager import Pager

from tableauserverclient.helpers.logging import logger

Expand Down Expand Up @@ -344,7 +349,34 @@ def add(self, user_item: UserItem) -> UserItem:

# Add new users to site. This does not actually perform a bulk action, it's syntactic sugar
@api(version="2.0")
def add_all(self, users: list[UserItem]):
def add_all(self, users: list[UserItem]) -> tuple[list[UserItem], list[UserItem]]:
"""
Syntactic sugar for calling users.add multiple times. This method has
been deprecated in favor of using the bulk_add which accomplishes the
same task in one API call.

.. deprecated:: v0.34.0
`add_all` will be removed as its functionality is replicated via
the `bulk_add` method.

Parameters
----------
users: list[UserItem]
A list of UserItem objects to add to the site. Each UserItem object
will be passed to the `add` method individually.

Returns
-------
tuple[list[UserItem], list[UserItem]]
The first element of the tuple is a list of UserItem objects that
were successfully added to the site. The second element is a list
of UserItem objects that failed to be added to the site.

Warnings
--------
This method is deprecated. Use the `bulk_add` method instead.
"""
warnings.warn("This method is deprecated, use bulk_add method instead.", DeprecationWarning)
created = []
failed = []
for user in users:
Expand All @@ -357,8 +389,138 @@ def add_all(self, users: list[UserItem]):

# helping the user by parsing a file they could have used to add users through the UI
# line format: Username [required], password, display name, license, admin, publish
@api(version="3.15")
def bulk_add(self, users: Iterable[UserItem]) -> JobItem:
"""
When adding users in bulk, the server will return a job item that can be used to track the progress of the
operation. This method will return the job item that was created when the users were added.

For each user, name is required, and other fields are optional. If connected to activte directory and
the user name is not unique across domains, then the domain attribute must be populated on
the UserItem.

The user's display name is read from the fullname attribute.

Email is optional, but if provided, it must be a valid email address.

If auth_setting is not provided, the default is ServerDefault.

If site_role is not provided, the default is Unlicensed.

Password is optional, and only used if the server is using local
authentication. If using any other authentication method, the password
should not be provided.

Details about administrator level and publishing capability are
inferred from the site_role.

Parameters
----------
users: Iterable[UserItem]
An iterable of UserItem objects to add to the site. See above for
what fields are required and optional.

Returns
-------
JobItem
The job that is started for adding the users in bulk.

Examples
--------
>>> import tableauserverclient as TSC
>>> server = TSC.Server('http://localhost')
>>> # Login to the server

>>> # Create a list of UserItem objects to add to the site
>>> users = [
>>> TSC.UserItem(name="user1", site_role="Unlicensed"),
>>> TSC.UserItem(name="user2", site_role="Explorer"),
>>> TSC.UserItem(name="user3", site_role="Creator"),
>>> ]

>>> # Set the domain name for the users
>>> for user in users:
>>> user.domain_name = "example.com"

>>> # Add the users to the site
>>> job = server.users.bulk_add(users)

"""
url = f"{self.baseurl}/import"
# Allow for iterators to be passed into the function
csv_users, xml_users = itertools.tee(users, 2)
csv_content = create_users_csv(csv_users)

xml_request, content_type = RequestFactory.User.import_from_csv_req(csv_content, xml_users)
server_response = self.post_request(url, xml_request, content_type)
return JobItem.from_response(server_response.content, self.parent_srv.namespace).pop()

@api(version="3.15")
def bulk_remove(self, users: Iterable[UserItem]) -> None:
"""
Remove multiple users from the site. The users are identified by their
domain and name. The users are removed in bulk, so the server will not
return a job item to track the progress of the operation nor a response
for each user that was removed.

Parameters
----------
users: Iterable[UserItem]
An iterable of UserItem objects to remove from the site. Each
UserItem object should have the domain and name attributes set.

Returns
-------
None

Examples
--------
>>> import tableauserverclient as TSC
>>> server = TSC.Server('http://localhost')
>>> # Login to the server

>>> # Find the users to remove
>>> example_users = server.users.filter(domain_name="example.com")
>>> server.users.bulk_remove(example_users)
"""
url = f"{self.baseurl}/delete"
csv_content = remove_users_csv(users)
request, content_type = RequestFactory.User.delete_csv_req(csv_content)
server_response = self.post_request(url, request, content_type)
return None

@api(version="2.0")
def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]:
"""
Syntactic sugar for calling users.add multiple times. This method has
been deprecated in favor of using the bulk_add which accomplishes the
same task in one API call.

.. deprecated:: v0.34.0
`add_all` will be removed as its functionality is replicated via
the `bulk_add` method.

Parameters
----------
filepath: str
The path to the CSV file containing the users to add to the site.
The file is read in line by line and each line is passed to the
`add` method.

Returns
-------
tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]
The first element of the tuple is a list of UserItem objects that
were successfully added to the site. The second element is a list
of tuples where the first element is the UserItem object that failed
to be added to the site and the second element is the ServerResponseError
that was raised when attempting to add the user.

Warnings
--------
This method is deprecated. Use the `bulk_add` method instead.
"""
warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning)
created = []
failed = []
if not filepath.find("csv"):
Expand Down Expand Up @@ -552,3 +714,117 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe
"""

return super().filter(*invalid, page_size=page_size, **kwargs)


def create_users_csv(users: Iterable[UserItem], identity_pool=None) -> bytes:
"""
Create a CSV byte string from an Iterable of UserItem objects. The CSV will
have the following columns, and no header row:

- Username
- Password
- Display Name
- License
- Admin Level
- Publish capability
- Email

Parameters
----------
users: Iterable[UserItem]
An iterable of UserItem objects to create the CSV from.

identity_pool: Optional[str]
The identity pool to use when adding the users. This parameter is not
yet supported in this version of the Tableau Server Client, and should
be left as None.

Returns
-------
bytes
A byte string containing the CSV data.
"""
if identity_pool is not None:
raise NotImplementedError("Identity pool is not supported in this version")
with io.StringIO() as output:
writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
for user in users:
site_role = user.site_role or "Unlicensed"
if site_role == "ServerAdministrator":
license = "Creator"
admin_level = "System"
elif site_role.startswith("SiteAdministrator"):
admin_level = "Site"
license = site_role.replace("SiteAdministrator", "")
else:
license = site_role
admin_level = ""

if any(x in site_role for x in ("Creator", "Admin", "Publish")):
publish = 1
else:
publish = 0

writer.writerow(
(
f"{user.domain_name}\\{user.name}" if user.domain_name else user.name,
getattr(user, "password", ""),
user.fullname,
license,
admin_level,
publish,
user.email,
)
)
output.seek(0)
result = output.read().encode("utf-8")
return result


def remove_users_csv(users: Iterable[UserItem]) -> bytes:
"""
Create a CSV byte string from an Iterable of UserItem objects. This function
only consumes the domain and name attributes of the UserItem objects. The
CSV will have space for the following columns, though only the first column
will be populated, and no header row:

- Username
- Password
- Display Name
- License
- Admin Level
- Publish capability
- Email

Parameters
----------
users: Iterable[UserItem]
An iterable of UserItem objects to create the CSV from.

identity_pool: Optional[str]
The identity pool to use when adding the users. This parameter is not
yet supported in this version of the Tableau Server Client, and should
be left as None.

Returns
-------
bytes
A byte string containing the CSV data.
"""
with io.StringIO() as output:
writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
for user in users:
writer.writerow(
(
f"{user.domain_name}\\{user.name}" if user.domain_name else user.name,
None,
None,
None,
None,
None,
None,
)
)
output.seek(0)
result = output.read().encode("utf-8")
return result
21 changes: 21 additions & 0 deletions tableauserverclient/server/request_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,27 @@ def add_req(self, user_item: UserItem) -> bytes:
user_element.attrib["authSetting"] = user_item.auth_setting
return ET.tostring(xml_request)

def import_from_csv_req(self, csv_content: bytes, users: Iterable[UserItem]):
xml_request = ET.Element("tsRequest")
for user in users:
if user.name is None:
raise ValueError("User name must be populated.")
user_element = ET.SubElement(xml_request, "user")
user_element.attrib["name"] = user.name
user_element.attrib["authSetting"] = user.auth_setting or "ServerDefault"

parts = {
"tableau_user_import": ("tsc_users_file.csv", csv_content, "file"),
"request_payload": ("", ET.tostring(xml_request), "text/xml"),
}
return _add_multipart(parts)

def delete_csv_req(self, csv_content: bytes):
parts = {
"tableau_user_delete": ("tsc_users_file.csv", csv_content, "file"),
}
return _add_multipart(parts)


class WorkbookRequest:
def _generate_xml(
Expand Down
4 changes: 4 additions & 0 deletions test/assets/users_bulk_add_job.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api https://help.tableau.com/samples/en-us/rest_api/ts-api_3_20.xsd">
<job id="16a3479e-0ff9-4685-a0e4-1533b3c2eb96" mode="Asynchronous" type="UserImport" progress="0" createdAt="2024-06-27T03:21:02Z" finishCode="1"/>
</tsResponse>
Loading
Loading