Skip to content
Draft
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
37 changes: 37 additions & 0 deletions tableauserverclient/models/user_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,43 @@ def _validate_attribute_value(item: str, possible_values: list[str], column_type
return
raise AttributeError(f"Invalid value {item} for {column_type}")

# Inverse of _evaluate_site_role: decompose a site role back to (license, admin_level, publish)
# for writing the CSV import format.
@staticmethod
def _decompose_site_role(site_role: str) -> tuple[str, str, str]:
"""Return (license, admin_level, publish) CSV column values for a given site role.

Legacy `UserItem.Roles` values are handled in two ways depending on whether
the server has a sensible modern equivalent:

- **Mapped to modern equivalents** (row emitted, server accepts): the legacy
roles `SiteAdministrator`, `Publisher`, `Interactor`, and `ReadOnly` each
map to the current-model role that best matches their historical intent
(SiteAdministratorExplorer, ExplorerCanPublish, Explorer, Viewer).
- **Emitted as `license="Invalid"`** (row rejected server-side with
USER_CSV_INVALID_LICENSE): the legacy roles `UnlicensedWithPublish`,
`ViewerWithPublish`, `Guest`, and `SupportUser` have no equivalent in the
current server model (`RestApiSiteRole` does not accept them on any code
path). Emitting `"Invalid"` preserves the per-row error semantics callers
of `bulk_add` had before this refactor, rather than silently coercing
those users to a valid-but-wrong Unlicensed account.
"""
_role_map: dict[str, tuple[str, str, str]] = {
"ServerAdministrator": ("Creator", "System", "1"),
"SiteAdministratorCreator": ("Creator", "Site", "1"),
"SiteAdministratorExplorer": ("Explorer", "Site", "1"),
"SiteAdministrator": ("Explorer", "Site", "1"), # legacy, mapped to SiteAdministratorExplorer
"Creator": ("Creator", "None", "1"),
"ExplorerCanPublish": ("Explorer", "None", "1"),
"Explorer": ("Explorer", "None", "0"),
"Viewer": ("Viewer", "None", "0"),
"Unlicensed": ("Unlicensed", "None", "0"),
"ReadOnly": ("Viewer", "None", "0"), # legacy, mapped to Viewer
"Publisher": ("Explorer", "None", "1"), # legacy, mapped to ExplorerCanPublish
"Interactor": ("Explorer", "None", "0"), # legacy, mapped to Explorer
}
return _role_map.get(site_role, ("Invalid", "None", "0"))

# 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
Expand Down
23 changes: 4 additions & 19 deletions tableauserverclient/server/endpoint/users_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us
warnings.warn("This method is deprecated, use bulk_add instead", DeprecationWarning)
created = []
failed = []
if not filepath.find("csv"):
if "csv" not in filepath:
raise ValueError("Only csv files are accepted")

with open(filepath) as csv_file:
Expand All @@ -536,11 +536,9 @@ def create_from_file(self, filepath: str) -> tuple[list[UserItem], list[tuple[Us
while line and line != "":
user: UserItem = UserItem.CSVImport.create_user_from_line(line)
try:
print(user)
result = self.add(user)
created.append(result)
except ServerResponseError as serverError:
print("failed")
failed.append((user, serverError))
line = csv_file.readline()
return created, failed
Expand Down Expand Up @@ -751,6 +749,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes:
- Admin Level
- Publish capability
- Email
- Auth setting

Parameters
----------
Expand All @@ -765,22 +764,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes:
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

license, admin_level, publish = UserItem.CSVImport._decompose_site_role(user.site_role or "Unlicensed")
writer.writerow(
(
f"{user.domain_name}\\{user.name}" if user.domain_name else user.name,
Expand All @@ -790,6 +774,7 @@ def create_users_csv(users: Iterable[UserItem]) -> bytes:
admin_level,
publish,
user.email,
user.auth_setting or "",
)
)
output.seek(0)
Expand Down
19 changes: 17 additions & 2 deletions test/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ def test_create_users_csv() -> None:
"ServerAdministrator": "System",
}

csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email"]
csv_columns = ["name", "password", "fullname", "license", "admin", "publish", "email", "auth"]
csv_data = create_users_csv(users)
csv_file = io.StringIO(csv_data.decode("utf-8"))
csv_reader = csv.reader(csv_file)
Expand All @@ -417,8 +417,23 @@ def test_create_users_csv() -> None:
assert (user.fullname or "") == csv_user["fullname"]
assert (user.email or "") == csv_user["email"]
assert license_map[site_role] == csv_user["license"]
assert admin_map.get(site_role, "") == csv_user["admin"]
assert admin_map.get(site_role, "None") == csv_user["admin"]
assert publish_map[site_role] == int(csv_user["publish"])
assert (user.auth_setting or "") == csv_user["auth"]


def test_decompose_unsupported_role_emits_invalid_license() -> None:
# UnlicensedWithPublish and ViewerWithPublish are in UserItem.Roles for
# historical reasons but the server-side CSV license parser has never
# accepted them. _decompose_site_role emits license="Invalid" for these
# (and any other unmapped role) so the server rejects the row with
# USER_CSV_INVALID_LICENSE, preserving the per-row error semantics
# callers of bulk_add had before this refactor.
for role in ("UnlicensedWithPublish", "ViewerWithPublish", "Guest", "SupportUser"):
license, admin, publish = TSC.UserItem.CSVImport._decompose_site_role(role)
assert license == "Invalid"
assert admin == "None"
assert publish == "0"


def test_bulk_add(server: TSC.Server) -> None:
Expand Down
Loading