11import io
2+ import warnings
23import xml .etree .ElementTree as ET
34from datetime import datetime
45from enum import IntEnum
@@ -476,12 +477,23 @@ def create_user_from_line(line: str):
476477 )
477478 raw_auth = values [UserItem .CSVImport .ColumnType .AUTH ]
478479 if raw_auth :
479- auth = UserItem .CSVImport ._AUTH_CANONICAL .get (raw_auth .lower ())
480- if auth is None :
481- raise ValueError (
482- f"Unknown auth setting: { raw_auth !r} . "
483- f"Valid values: { sorted (UserItem .CSVImport ._AUTH_CANONICAL .values ())} "
480+ canonical = UserItem .CSVImport ._AUTH_CANONICAL .get (raw_auth .lower ())
481+ if canonical is None :
482+ # Unknown auth value: pass it through instead of raising.
483+ # TSC's _AUTH_CANONICAL is a hardcoded list that will lag
484+ # server-side additions; refusing to build the UserItem
485+ # here would block CSV imports against newer servers as
486+ # soon as Tableau ships a new auth type. If it is a
487+ # typo, the server rejects the row when the request
488+ # posts. Warn so the caller has a shot at noticing.
489+ warnings .warn (
490+ f"Unknown auth setting { raw_auth !r} ; passing through unchanged. "
491+ f"Known values: { sorted (UserItem .CSVImport ._AUTH_CANONICAL .values ())} " ,
492+ stacklevel = 2 ,
484493 )
494+ auth = raw_auth
495+ else :
496+ auth = canonical
485497 else :
486498 auth = None
487499 user ._set_values (
@@ -546,14 +558,33 @@ def _validate_import_line_or_throw(incoming, logger) -> None:
546558 for i in range (1 , len (line )):
547559 value = line [i ]
548560 valid = _valid_attributes [i ]
561+ column = UserItem .CSVImport .ColumnType (i )
549562 # normalize case for fields with a restricted value set
563+ skip_validation = False
550564 if valid :
551565 if i == UserItem .CSVImport .ColumnType .AUTH :
552- value = UserItem .CSVImport ._AUTH_CANONICAL .get (value .lower (), value )
566+ canonical = UserItem .CSVImport ._AUTH_CANONICAL .get (value .lower ())
567+ if canonical is not None :
568+ value = canonical
569+ elif value :
570+ # Unknown auth value: warn and pass through instead
571+ # of raising. TSC's _AUTH_CANONICAL is a hardcoded
572+ # list that lags server-side additions; refusing
573+ # would block CSV imports against newer servers as
574+ # soon as Tableau ships a new auth type. Skip the
575+ # allowlist check so the row still validates.
576+ # Matches create_user_from_line's warn-and-pass.
577+ warnings .warn (
578+ f"Unknown auth setting { value !r} ; passing through unchanged. "
579+ f"Known values: { sorted (UserItem .CSVImport ._AUTH_CANONICAL .values ())} " ,
580+ stacklevel = 2 ,
581+ )
582+ skip_validation = True
553583 else :
554584 value = value .lower ()
555- logger .debug (f"column { UserItem .CSVImport .ColumnType (i ).name } : { value } " )
556- UserItem .CSVImport ._validate_attribute_value (value , valid , UserItem .CSVImport .ColumnType (i ))
585+ logger .debug (f"column { column .name } : { value } " )
586+ if not skip_validation :
587+ UserItem .CSVImport ._validate_attribute_value (value , valid , column )
557588
558589 # Given a restricted set of possible values, confirm the item is in that set
559590 @staticmethod
@@ -565,6 +596,49 @@ def _validate_attribute_value(item: str, possible_values: list[str], column_type
565596 return
566597 raise ValueError (f"Invalid value { item } for { column_type } " )
567598
599+ # Inverse of _evaluate_site_role: decompose a site role back to (license, admin_level, publish)
600+ # for writing the CSV import format.
601+ @staticmethod
602+ def _decompose_site_role (site_role : str ) -> tuple [str , str , str ]:
603+ """Return (license, admin_level, publish) CSV column values for a given site role.
604+
605+ Legacy `UserItem.Roles` values are handled in two ways depending on whether
606+ the server has a sensible modern equivalent:
607+
608+ - **Mapped to modern equivalents** (row emitted, server accepts): the legacy
609+ roles `SiteAdministrator`, `Publisher`, `Interactor`, and `ReadOnly` each
610+ map to the current-model role that best matches their historical intent
611+ (SiteAdministratorExplorer, ExplorerCanPublish, Explorer, Viewer).
612+ - **Emitted as `license="Invalid"`** (row rejected server-side with
613+ USER_CSV_INVALID_LICENSE): the legacy roles `UnlicensedWithPublish`,
614+ `ViewerWithPublish`, `Guest`, and `SupportUser` have no equivalent in the
615+ current server model (`RestApiSiteRole` does not accept them on any code
616+ path). Emitting `"Invalid"` preserves the per-row error semantics callers
617+ of `bulk_add` had before this refactor, rather than silently coercing
618+ those users to a valid-but-wrong Unlicensed account.
619+
620+ Round-trip note: `_evaluate_site_role(*_decompose_site_role(r)) == r` for
621+ every current-model role. Two label asymmetries: `ServerAdministrator`
622+ round-trips through the legacy label `SiteAdministrator` (that's the only
623+ label `_evaluate_site_role` emits for `admin="System"`), and the legacy
624+ roles above are folded into their modern equivalents by design.
625+ """
626+ _role_map : dict [str , tuple [str , str , str ]] = {
627+ "ServerAdministrator" : ("Creator" , "System" , "1" ),
628+ "SiteAdministratorCreator" : ("Creator" , "Site" , "1" ),
629+ "SiteAdministratorExplorer" : ("Explorer" , "Site" , "1" ),
630+ "SiteAdministrator" : ("Explorer" , "Site" , "1" ), # legacy, mapped to SiteAdministratorExplorer
631+ "Creator" : ("Creator" , "None" , "1" ),
632+ "ExplorerCanPublish" : ("Explorer" , "None" , "1" ),
633+ "Explorer" : ("Explorer" , "None" , "0" ),
634+ "Viewer" : ("Viewer" , "None" , "0" ),
635+ "Unlicensed" : ("Unlicensed" , "None" , "0" ),
636+ "ReadOnly" : ("Viewer" , "None" , "0" ), # legacy, mapped to Viewer
637+ "Publisher" : ("Explorer" , "None" , "1" ), # legacy, mapped to ExplorerCanPublish
638+ "Interactor" : ("Explorer" , "None" , "0" ), # legacy, mapped to Explorer
639+ }
640+ return _role_map .get (site_role , ("Invalid" , "None" , "0" ))
641+
568642 # https://help.tableau.com/current/server/en-us/csvguidelines.htm#settings_and_site_roles
569643 # This logic is hardcoded to match the existing rules for import csv files
570644 @staticmethod
@@ -586,14 +660,14 @@ def _evaluate_site_role(license_level, admin_level, publisher):
586660 else :
587661 site_role = "SiteAdministratorExplorer"
588662 else : # if it wasn't 'system' or 'site' then we can treat it as 'none'
589- if publisher == "yes" :
663+ if publisher in ( "yes" , "true" , "1" ) :
590664 if license_level == "creator" :
591665 site_role = "Creator"
592666 elif license_level == "explorer" :
593667 site_role = "ExplorerCanPublish"
594668 else :
595669 site_role = "Unlicensed" # is this the expected outcome?
596- else : # publisher == 'no' :
670+ else : # publisher is "no" / "false" / "0" / any other value :
597671 if license_level == "explorer" or license_level == "creator" :
598672 site_role = "Explorer"
599673 elif license_level == "viewer" :
0 commit comments