Skip to content

Commit fe97749

Browse files
authored
feat: users csv import (#1409)
* fix: black ci errors * feat: enable bulk adding users * feat: ensure domain name is included if provided * style: black * chore: test missing user name * feat: implement users bulk_remove * chore: suppress deprecation warning in test * chore: split csv add creation to own test * chore: use subTests in remove_users * chore: user factory function in make_user * docs: bulk_add docstring * fix: assert on warning instead of ignore * chore: missed an absolute import * docs: bulk_add docstring * docs: create_users_csv docstring * chore: deprecate add_all method * test: test add_all and check DeprecationWarning * docs: docstring updates for bulk add operations * docs: add examples to docstrings * chore: update deprecated version # * feat: enable idp_configuration_id in bulk_add * chore: remove outdated docstring text * test: remove_users_csv * chore: update deprecated version number * chore: pytestify test_user * chore: pytestify test_user_model * style: black --------- Co-authored-by: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
1 parent 1e116f3 commit fe97749

File tree

4 files changed

+544
-7
lines changed

4 files changed

+544
-7
lines changed

tableauserverclient/server/endpoint/users_endpoint.py

Lines changed: 274 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
from collections.abc import Iterable
12
import copy
3+
import csv
4+
import io
5+
import itertools
26
import logging
37
from typing import Optional
8+
import warnings
49

510
from tableauserverclient.server.query import QuerySet
611

7-
from .endpoint import QuerysetEndpoint, api
8-
from .exceptions import MissingRequiredFieldError, ServerResponseError
12+
from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
13+
from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError, ServerResponseError
914
from tableauserverclient.server import RequestFactory, RequestOptions
10-
from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem
11-
from ..pager import Pager
15+
from tableauserverclient.models import UserItem, WorkbookItem, PaginationItem, GroupItem, JobItem
16+
from tableauserverclient.server.pager import Pager
1217

1318
from tableauserverclient.helpers.logging import logger
1419

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

345350
# Add new users to site. This does not actually perform a bulk action, it's syntactic sugar
346351
@api(version="2.0")
347-
def add_all(self, users: list[UserItem]):
352+
def add_all(self, users: list[UserItem]) -> tuple[list[UserItem], list[UserItem]]:
353+
"""
354+
Syntactic sugar for calling users.add multiple times. This method has
355+
been deprecated in favor of using the bulk_add which accomplishes the
356+
same task in one API call.
357+
358+
.. deprecated:: v0.41.0
359+
`add_all` will be removed as its functionality is replicated via
360+
the `bulk_add` method.
361+
362+
Parameters
363+
----------
364+
users: list[UserItem]
365+
A list of UserItem objects to add to the site. Each UserItem object
366+
will be passed to the `add` method individually.
367+
368+
Returns
369+
-------
370+
tuple[list[UserItem], list[UserItem]]
371+
The first element of the tuple is a list of UserItem objects that
372+
were successfully added to the site. The second element is a list
373+
of UserItem objects that failed to be added to the site.
374+
375+
Warnings
376+
--------
377+
This method is deprecated. Use the `bulk_add` method instead.
378+
"""
379+
warnings.warn("This method is deprecated, use bulk_add method instead.", DeprecationWarning)
348380
created = []
349381
failed = []
350382
for user in users:
@@ -357,8 +389,143 @@ def add_all(self, users: list[UserItem]):
357389

358390
# helping the user by parsing a file they could have used to add users through the UI
359391
# line format: Username [required], password, display name, license, admin, publish
392+
@api(version="3.15")
393+
def bulk_add(self, users: Iterable[UserItem]) -> JobItem:
394+
"""
395+
When adding users in bulk, the server will return a job item that can be used to track the progress of the
396+
operation. This method will return the job item that was created when the users were added.
397+
398+
For each user, name is required, and other fields are optional. If connected to activte directory and
399+
the user name is not unique across domains, then the domain attribute must be populated on
400+
the UserItem.
401+
402+
The user's display name is read from the fullname attribute.
403+
404+
Email is optional, but if provided, it must be a valid email address.
405+
406+
If auth_setting is not provided, and idp_configuration_id is None, then
407+
default is ServerDefault.
408+
409+
If site_role is not provided, the default is Unlicensed.
410+
411+
Password is optional, and only used if the server is using local
412+
authentication. If using any other authentication method, the password
413+
should not be provided.
414+
415+
Details about administrator level and publishing capability are
416+
inferred from the site_role.
417+
418+
If the user belongs to a different IDP configuration, the UserItem's
419+
idp_configuration_id attribute must be set to the IDP configuration ID
420+
that the user belongs to.
421+
422+
Parameters
423+
----------
424+
users: Iterable[UserItem]
425+
An iterable of UserItem objects to add to the site. See above for
426+
what fields are required and optional.
427+
428+
Returns
429+
-------
430+
JobItem
431+
The job that is started for adding the users in bulk.
432+
433+
Examples
434+
--------
435+
>>> import tableauserverclient as TSC
436+
>>> server = TSC.Server('http://localhost')
437+
>>> # Login to the server
438+
439+
>>> # Create a list of UserItem objects to add to the site
440+
>>> users = [
441+
>>> TSC.UserItem(name="user1", site_role="Unlicensed"),
442+
>>> TSC.UserItem(name="user2", site_role="Explorer"),
443+
>>> TSC.UserItem(name="user3", site_role="Creator"),
444+
>>> ]
445+
446+
>>> # Set the domain name for the users
447+
>>> for user in users:
448+
>>> user.domain_name = "example.com"
449+
450+
>>> # Add the users to the site
451+
>>> job = server.users.bulk_add(users)
452+
453+
"""
454+
url = f"{self.baseurl}/import"
455+
# Allow for iterators to be passed into the function
456+
csv_users, xml_users = itertools.tee(users, 2)
457+
csv_content = create_users_csv(csv_users)
458+
459+
xml_request, content_type = RequestFactory.User.import_from_csv_req(csv_content, xml_users)
460+
server_response = self.post_request(url, xml_request, content_type)
461+
return JobItem.from_response(server_response.content, self.parent_srv.namespace).pop()
462+
463+
@api(version="3.15")
464+
def bulk_remove(self, users: Iterable[UserItem]) -> None:
465+
"""
466+
Remove multiple users from the site. The users are identified by their
467+
domain and name. The users are removed in bulk, so the server will not
468+
return a job item to track the progress of the operation nor a response
469+
for each user that was removed.
470+
471+
Parameters
472+
----------
473+
users: Iterable[UserItem]
474+
An iterable of UserItem objects to remove from the site. Each
475+
UserItem object should have the domain and name attributes set.
476+
477+
Returns
478+
-------
479+
None
480+
481+
Examples
482+
--------
483+
>>> import tableauserverclient as TSC
484+
>>> server = TSC.Server('http://localhost')
485+
>>> # Login to the server
486+
487+
>>> # Find the users to remove
488+
>>> example_users = server.users.filter(domain_name="example.com")
489+
>>> server.users.bulk_remove(example_users)
490+
"""
491+
url = f"{self.baseurl}/delete"
492+
csv_content = remove_users_csv(users)
493+
request, content_type = RequestFactory.User.delete_csv_req(csv_content)
494+
server_response = self.post_request(url, request, content_type)
495+
return None
496+
360497
@api(version="2.0")
361498
def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]:
499+
"""
500+
Syntactic sugar for calling users.add multiple times. This method has
501+
been deprecated in favor of using the bulk_add which accomplishes the
502+
same task in one API call.
503+
504+
.. deprecated:: v0.41.0
505+
`add_all` will be removed as its functionality is replicated via
506+
the `bulk_add` method.
507+
508+
Parameters
509+
----------
510+
filepath: str
511+
The path to the CSV file containing the users to add to the site.
512+
The file is read in line by line and each line is passed to the
513+
`add` method.
514+
515+
Returns
516+
-------
517+
tuple[list[UserItem], list[tuple[UserItem, ServerResponseError]]]
518+
The first element of the tuple is a list of UserItem objects that
519+
were successfully added to the site. The second element is a list
520+
of tuples where the first element is the UserItem object that failed
521+
to be added to the site and the second element is the ServerResponseError
522+
that was raised when attempting to add the user.
523+
524+
Warnings
525+
--------
526+
This method is deprecated. Use the `bulk_add` method instead.
527+
"""
528+
warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning)
362529
created = []
363530
failed = []
364531
if not filepath.find("csv"):
@@ -569,3 +736,105 @@ def filter(self, *invalid, page_size: Optional[int] = None, **kwargs) -> QuerySe
569736
"""
570737

571738
return super().filter(*invalid, page_size=page_size, **kwargs)
739+
740+
741+
def create_users_csv(users: Iterable[UserItem]) -> bytes:
742+
"""
743+
Create a CSV byte string from an Iterable of UserItem objects. The CSV will
744+
have the following columns, and no header row:
745+
746+
- Username
747+
- Password
748+
- Display Name
749+
- License
750+
- Admin Level
751+
- Publish capability
752+
- Email
753+
754+
Parameters
755+
----------
756+
users: Iterable[UserItem]
757+
An iterable of UserItem objects to create the CSV from.
758+
759+
Returns
760+
-------
761+
bytes
762+
A byte string containing the CSV data.
763+
"""
764+
with io.StringIO() as output:
765+
writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
766+
for user in users:
767+
site_role = user.site_role or "Unlicensed"
768+
if site_role == "ServerAdministrator":
769+
license = "Creator"
770+
admin_level = "System"
771+
elif site_role.startswith("SiteAdministrator"):
772+
admin_level = "Site"
773+
license = site_role.replace("SiteAdministrator", "")
774+
else:
775+
license = site_role
776+
admin_level = ""
777+
778+
if any(x in site_role for x in ("Creator", "Admin", "Publish")):
779+
publish = 1
780+
else:
781+
publish = 0
782+
783+
writer.writerow(
784+
(
785+
f"{user.domain_name}\\{user.name}" if user.domain_name else user.name,
786+
getattr(user, "password", ""),
787+
user.fullname,
788+
license,
789+
admin_level,
790+
publish,
791+
user.email,
792+
)
793+
)
794+
output.seek(0)
795+
result = output.read().encode("utf-8")
796+
return result
797+
798+
799+
def remove_users_csv(users: Iterable[UserItem]) -> bytes:
800+
"""
801+
Create a CSV byte string from an Iterable of UserItem objects. This function
802+
only consumes the domain and name attributes of the UserItem objects. The
803+
CSV will have space for the following columns, though only the first column
804+
will be populated, and no header row:
805+
806+
- Username
807+
- Password
808+
- Display Name
809+
- License
810+
- Admin Level
811+
- Publish capability
812+
- Email
813+
814+
Parameters
815+
----------
816+
users: Iterable[UserItem]
817+
An iterable of UserItem objects to create the CSV from.
818+
819+
Returns
820+
-------
821+
bytes
822+
A byte string containing the CSV data.
823+
"""
824+
with io.StringIO() as output:
825+
writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
826+
for user in users:
827+
writer.writerow(
828+
(
829+
f"{user.domain_name}\\{user.name}" if user.domain_name else user.name,
830+
None,
831+
None,
832+
None,
833+
None,
834+
None,
835+
None,
836+
)
837+
)
838+
output.seek(0)
839+
result = output.read().encode("utf-8")
840+
return result

tableauserverclient/server/request_factory.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,32 @@ def add_req(self, user_item: UserItem) -> bytes:
985985
user_element.attrib["idpConfigurationId"] = user_item.idp_configuration_id
986986
return ET.tostring(xml_request)
987987

988+
def import_from_csv_req(self, csv_content: bytes, users: Iterable[UserItem]):
989+
xml_request = ET.Element("tsRequest")
990+
for user in users:
991+
if user.name is None:
992+
raise ValueError("User name must be populated.")
993+
user_element = ET.SubElement(xml_request, "user")
994+
user_element.attrib["name"] = user.name
995+
if user.auth_setting is not None and user.idp_configuration_id is not None:
996+
raise ValueError("User cannot have both authSetting and idpConfigurationId.")
997+
elif user.idp_configuration_id is not None:
998+
user_element.attrib["idpConfigurationId"] = user.idp_configuration_id
999+
else:
1000+
user_element.attrib["authSetting"] = user.auth_setting or "ServerDefault"
1001+
1002+
parts = {
1003+
"tableau_user_import": ("tsc_users_file.csv", csv_content, "file"),
1004+
"request_payload": ("", ET.tostring(xml_request), "text/xml"),
1005+
}
1006+
return _add_multipart(parts)
1007+
1008+
def delete_csv_req(self, csv_content: bytes):
1009+
parts = {
1010+
"tableau_user_delete": ("tsc_users_file.csv", csv_content, "file"),
1011+
}
1012+
return _add_multipart(parts)
1013+
9881014

9891015
class WorkbookRequest:
9901016
def _generate_xml(

test/assets/users_bulk_add_job.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<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">
3+
<job id="16a3479e-0ff9-4685-a0e4-1533b3c2eb96" mode="Asynchronous" type="UserImport" progress="0" createdAt="2024-06-27T03:21:02Z" finishCode="1"/>
4+
</tsResponse>

0 commit comments

Comments
 (0)