8000 Jac/user import by jacalata · Pull Request #1086 · tableau/server-client-python · GitHub
[go: up one dir, main page]

Skip to content

Jac/user import #1086

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

Merged
merged 11 commits into from
Aug 27, 2022
Merged
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
48 changes: 45 additions & 3 deletions samples/create_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@

import argparse
import logging
import os

from datetime import time
from typing import List

import tableauserverclient as TSC
from tableauserverclient import ServerResponseError


def main():
Expand All @@ -35,7 +38,7 @@ def main():
)
# Options specific to this sample
# This sample has no additional options, yet. If you add some, please add them here

parser.add_argument("--file", help="csv file containing user info", required=False)
args = parser.parse_args()

# Set logging level based on user input, or error by default
Expand All @@ -45,9 +48,48 @@ def main():
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):
# this code shows 3 different error codes that mean "resource is already in collection"
# 409009: group already exists on server
# 409107: user is already on site
# 409011: user is already in group

group = TSC.GroupItem("test")
group = server.groups.create(group)
print(group)
try:
group = server.groups.create(group)
except TSC.server.endpoint.exceptions.ServerResponseError as rError:
if rError.code == "409009":
print("Group already exists")
group = server.groups.filter(name=group.name)[0]
else:
raise rError
server.groups.populate_users(group)
for user in group.users:
print(user.name)

if args.file:
filepath = os.path.abspath(args.file)
print("Add users to site from file {}:".format(filepath))
added: List[TSC.UserItem]
failed: List[TSC.UserItem, TSC.ServerResponseError]
added, failed = server.users.create_from_file(filepath)
for user, error in failed:
print(user, error.code)
if error.code == "409017":
user = server.users.filter(name=user.name)[0]
added.append(user)
print("Adding users to group:{}".format(added))
for user in added:
print("Adding user {}".format(user))
try:
server.groups.add_user(group, user.id)
except ServerResponseError as serverError:
if serverError.code == "409011":
print("user {} is already a member of group {}".format(user.name, group.name))
else:
raise rError

for user in group.users:
print(user.name)


if __name__ == "__main__":
Expand Down
2 changes: 2 additions & 0 deletions samples/online_users.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ayoung@tableau.com, , , "Creator", None, Yes
ahsiao@tableau.com, , , "Explorer", None, No
8 changes: 5 additions & 3 deletions tableauserverclient/models/group_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ def __init__(self, name=None, domain_name=None) -> None:
self.name: Optional[str] = name
self.domain_name: Optional[str] = domain_name

def __str__(self):
return "{}({!r})".format(self.__class__.__name__, self.__dict__)

__repr__ = __str__

@property
def domain_name(self) -> Optional[str]:
return self._domain_name
Expand Down Expand Up @@ -74,9 +79,6 @@ def users(self) -> "Pager":
# Each call to `.users` should create a new pager, this just runs the callable
return self._users()

def to_reference(self) -> ResourceReference:
return ResourceReference(id_=self.id, tag_name=self.tag_name)

def _set_users(self, users: Callable[..., "Pager"]) -> None:
self._users = users

Expand Down
180 changes: 167 additions & 13 deletions tableauserverclient/models/user_item.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
from datetime import datetime
import io
import logging
import xml.etree.ElementTree as ET
from datetime import datetime
from typing import Dict, List, Optional, TYPE_CHECKING
from enum import IntEnum

from defusedxml.ElementTree import fromstring

from .exceptions import UnpopulatedPropertyError
from .property_decorators import (
property_is_enum,
property_not_empty,
property_not_nullable,
)
from .reference_item import ResourceReference
from ..datetime_helpers import parse_datetime

if TYPE_CHECKING:
from ..server.pager import Pager

from typing import Dict, List, Optional, TYPE_CHECKING
from typing import Dict, List, Optional, TYPE_CHECKING, Tuple

if TYPE_CHECKING:
from ..server.pager import Pager
Expand Down Expand Up @@ -72,6 +69,10 @@ def __init__(

return None

def __repr__(self) -> str:
str_site_role = self.site_role or "None"
return "<User {} name={} role={}>".format(self.id, self.name, str_site_role)

@property
def auth_setting(self) -> Optional[str]:
return self._auth_setting
Expand Down Expand Up @@ -106,12 +107,24 @@ def name(self) -> Optional[str]:
def name(self, value: str):
self._name = value

# valid: username, domain/username, username@domain, domain/username@email
@staticmethod
def validate_username_or_throw(username) -> None:
if username is None or username == "" or username.strip(" ") == "":
raise AttributeError("Username cannot be empty")
if username.find(" ") >= 0:
raise AttributeError("Username cannot contain spaces")
at_symbol = username.find("@")
if at_symbol >= 0:
username = username[:at_symbol] + "X" + username[at_symbol + 1 :]
if username.find("@") >= 0:
raise AttributeError("Username cannot repeat '@'")

@property
def site_role(self) -> Optional[str]:
return self._site_role

@site_role.setter
@property_not_nullable
@property_is_enum(Roles)
def site_role(self, value):
self._site_role = value
Expand All @@ -137,9 +150,6 @@ def groups(self) -> "Pager":
raise UnpopulatedPropertyError(error)
return self._groups()

def to_reference(self) -> ResourceReference:
return ResourceReference(id_=self.id, tag_name=self.tag_name)

def _set_workbooks(self, workbooks) -> None:
self._workbooks = workbooks

Expand Down Expand Up @@ -259,5 +269,149 @@ def _parse_element(user_xml, ns):
domain_name,
)

def __repr__(self) -> str:
return "<User {} name={} role={}>".format(self.id, self.name, self.site_role)
class CSVImport(object):
"""
This class includes hardcoded options and logic for the CSV file format defined for user import
https://help.tableau.com/current/server/en-us/users_import.htm
"""

# username, password, display_name, license, admin_level, publishing, email, auth type
class ColumnType(IntEnum):
USERNAME = 0
PASS = 1
DISPLAY_NAME = 2
LICENSE = 3 # aka site role
ADMIN = 4
PUBLISHER = 5
EMAIL = 6
AUTH = 7

MAX = 7

# Read a csv line and create a user item populated by the given attributes
@staticmethod
def create_user_from_line(line: str):
if line is None or line is False or line == "\n" or line == "":
return None
line = line.strip().lower()
values: List[str] = list(map(str.strip, line.split(",")))
user = UserItem(values[UserItem.CSVImport.ColumnType.USERNAME])
if len(values) > 1:
if len(values) > UserItem.CSVImport.ColumnType.MAX:
raise ValueError("Too many attributes for user import")
while len(values) <= UserItem.CSVImport.ColumnType.MAX:
values.append("")
site_role = UserItem.CSVImport._evaluate_site_role(
values[UserItem.CSVImport.ColumnType.LICENSE],
values[UserItem.CSVImport.ColumnType.ADMIN],
values[UserItem.CSVImport.ColumnType.PUBLISHER],
)

user._set_values(
None,
values[UserItem.CSVImport.ColumnType.USERNAME],
site_role,
None,
None,
values[UserItem.CSVImport.ColumnType.DISPLAY_NAME],
values[UserItem.CSVImport.ColumnType.EMAIL],
values[UserItem.CSVImport.ColumnType.AUTH],
None,
)
return user

# Read through an entire CSV file meant for user import
# Return the number of valid lines and a list of all the invalid lines
@staticmethod
def validate_file_for_import(csv_file: io.TextIOWrapper, logger) -> Tuple[int, List[str]]:
num_valid_lines = 0
invalid_lines = []
csv_file.seek(0) # set to start of file in case it has been read earlier
line: str = csv_file.readline()
while line and line != "":
try:
# do not print passwords
logger.info("Reading user {}".format(line[:4]))
UserItem.CSVImport._validate_import_line_or_throw(line, logger)
num_valid_lines += 1
except Exception as exc:
logger.info("Error parsing {}: {}".format(line[:4], exc))
invalid_lines.append(line)
line = csv_file.readline()
return num_valid_lines, invalid_lines

# Some fields in the import file are restricted to specific values
# Iterate through each field and validate the given value against hardcoded constraints
@staticmethod
def _validate_import_line_or_throw(incoming, logger) -> None:
_valid_attributes: List[List[str]] = [
[],
[],
[],
["creator", "explorer", "viewer", "unlicensed"], # license
["system", "site", "none", "no"], # admin
["yes", "true", "1", "no", "false", "0"], # publisher
[],
[UserItem.Auth.SAML, UserItem.Auth.OpenID, UserItem.Auth.ServerDefault], # auth
]

line = list(map(str.strip, incoming.split(",")))
if len(line) > UserItem.CSVImport.ColumnType.MAX:
raise AttributeError("Too many attributes in line")
username = line[UserItem.CSVImport.ColumnType.USERNAME.value]
logger.debug("> details - {}".format(username))
UserItem.validate_username_or_throw(username)
for i in range(1, len(line)):
logger.debug("column {}: {}".format(UserItem.CSVImport.ColumnType(i).name, line[i]))
UserItem.CSVImport._validate_attribute_value(
line[i], _valid_attributes[i], UserItem.CSVImport.ColumnType(i)
)

# Given a restricted set of possible values, confirm the item is in that set
@staticmethod
def _validate_attribute_value(item: str, possible_values: List[str], column_type) -> None:
if item is None or item == "":
# value can be empty for any column except user, which is checked elsewhere
return
if item in possible_values or possible_values == []:
return
raise AttributeError("Invalid value {} for {}".format(item, column_type))

# https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles
# This logic is hardcoded to match the existing rules for import csv files
@staticmethod
def _evaluate_site_role(license_level, admin_level, publisher):
if not license_level or not admin_level or not publisher:
return "Unlicensed"
# ignore case everywhere
license_level = license_level.lower()
admin_level = admin_level.lower()
publisher = publisher.lower()
# don't need to check publisher for system/site admin
if admin_level == "system":
site_role = "SiteAdministrator"
elif admin_level == "site":
if license_level == "creator":
site_role = "SiteAdministratorCreator"
elif license_level == "explorer":
site_role = "SiteAdministratorExplorer"
else:
site_role = "SiteAdministratorExplorer"
else: # if it wasn't 'system' or 'site' then we can treat it as 'none'
if publisher == "yes":
if license_level == "creator":
site_role = "Creator"
elif license_level == "explorer":
site_role = "ExplorerCanPublish"
else:
site_role = "Unlicensed" # is this the expected outcome?
else: # publisher == 'no':
if license_level == "explorer" or license_level == "creator":
site_role = "Explorer"
elif license_level == "viewer":
site_role = "Viewer"
else: # if license_level == 'unlicensed'
site_role = "Unlicensed"
if site_role is None:
site_role = "Unlicensed"
return site_role
4 changes: 2 additions & 2 deletions tableauserverclient/server/endpoint/auth_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def sign_in(self, auth_req):
signin_req = RequestFactory.Auth.signin_req(auth_req)
server_response = self.parent_srv.session.post(url, data=signin_req, **self.parent_srv.http_options)
self.parent_srv._namespace.detect(server_response.content)
self._check_status(server_response)
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)
user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None)
Expand Down Expand Up @@ -66,7 +66,7 @@ def switch_site(self, site_item):
else:
raise e
self.parent_srv._namespace.detect(server_response.content)
self._check_status(server_response)
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)
user_id = parsed_response.find(".//t:user", namespaces=self.parent_srv.namespace).get("id", None)
Expand Down
8 changes: 4 additions & 4 deletions tableauserverclient/server/endpoint/endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,13 @@ def _make_request(

return server_response

def _check_status(self, server_response, request_url=None):
def _check_status(self, server_response, url: str = None):
if server_response.status_code >= 500:
raise InternalServerError(server_response, request_url)
raise InternalServerError(server_response, url)
elif server_response.status_code not in Success_codes:
# todo: is an error reliably of content-type application/xml?
try:
raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace)
raise ServerResponseError.from_response(server_response.content, self.parent_srv.namespace, url)
except ParseError:
# This will happen if we get a non-success HTTP code that
# doesn't return an xml error object (like metadata endpoints or 503 pages)
Expand Down Expand Up @@ -126,7 +126,7 @@ def get_request(self, url, request_object=None, parameters=None):
)

def delete_request(self, url):
# We don't return anything for a delete
# We don't return anything for a delete request
self._make_request(self.parent_srv.session.delete, url, auth_token=self.parent_srv.auth_token)

def put_request(self, url, xml_request=None, content_type=XML_CONTENT_TYPE, parameters=None):
Expand Down
Loading
0