1+ from collections .abc import Iterable
12import copy
3+ import csv
4+ import io
5+ import itertools
26import logging
37from typing import Optional
8+ import warnings
49
510from 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
914from 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
1318from 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
0 commit comments