diff --git a/CHANGELOG.md b/CHANGELOG.md index 943436b27..fc1430d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by level using the REST API name filter, so a path with *n* components issues *n* requests. Returns the matching `ProjectItem` or `None` if no project is found. +* Added `JobItem.status_notes` for the structured `` block documented on the Query + Job REST endpoint. Populated for UserImport and other multi-row jobs where + individual rows have distinct outcomes; each entry is a dict with keys + `type` / `value` / `text`. The existing `notes: list[str]` attribute is + unchanged (it parses the separate legacy `` element still emitted by + some job types). Fixes #1850. ## 0.18.0 (6 April 2022) * Switched to using defused_xml for xml attack protection diff --git a/tableauserverclient/models/job_item.py b/tableauserverclient/models/job_item.py index f684c22d4..ae6150eb5 100644 --- a/tableauserverclient/models/job_item.py +++ b/tableauserverclient/models/job_item.py @@ -49,7 +49,19 @@ class JobItem: The finish code of the job. 0 for success, 1 for failure, 2 for cancelled. notes : list[str] | None - Contains detailed notes about the job. + Detail notes emitted by legacy job types (e.g. extract refresh) inside + job-specific elements like `...`. + For modern job types see `status_notes`. + + status_notes : list[dict] | None + Structured per-row / per-metric status entries from the modern job response + schema (``). + Each element is a dict with keys `type`, `value`, `text` (any of which may + be None if the server omitted them). Populated for UserImport and other + multi-row jobs where individual rows have distinct outcomes; documented + types include `CountOfUsersAddedToSite`, `CountOfUsersSkipped`, + `CountOfUsersWithInsufficientLicenses`, etc. + See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job mode : str | None @@ -100,6 +112,7 @@ def __init__( updated_at: datetime.datetime | None = None, workbook_name: str | None = None, datasource_name: str | None = None, + status_notes: list[dict] | None = None, ): self._id = id_ self._type = job_type @@ -116,6 +129,7 @@ def __init__( self._updated_at = updated_at self._workbook_name = workbook_name self._datasource_name = datasource_name + self._status_notes: list[dict] = status_notes or [] @property def id(self) -> str: @@ -149,6 +163,10 @@ def finish_code(self) -> int: def notes(self) -> list[str]: return self._notes + @property + def status_notes(self) -> list[dict]: + return self._status_notes + @property def mode(self) -> str | None: return self._mode @@ -222,6 +240,14 @@ def _parse_element(cls, element, ns): completed_at = parse_datetime(element.get("completedAt", None)) finish_code = int(element.get("finishCode", -1)) notes = [note.text for note in element.findall(".//t:notes", namespaces=ns)] or None + status_notes = [ + { + "type": note.get("type"), + "value": note.get("value"), + "text": note.get("text"), + } + for note in element.findall(".//t:statusNotes/t:statusNote", namespaces=ns) + ] or None mode = element.get("mode", None) workbook = element.find(".//t:workbook[@id]", namespaces=ns) workbook_id = workbook.get("id") if workbook is not None else None @@ -253,6 +279,7 @@ def _parse_element(cls, element, ns): updated_at, workbook_name, datasource_name, + status_notes, ) diff --git a/test/test_job.py b/test/test_job.py index 19f324d1e..7bfdd1840 100644 --- a/test/test_job.py +++ b/test/test_job.py @@ -63,6 +63,53 @@ def test_get_by_id(server: TSC.Server) -> None: assert job_id == job.id assert updated_at == job.updated_at assert job.notes == ["Job detail notes"] + # Regression for #1850: the response also carries a + # block per the public REST doc. Verify it now surfaces via job.status_notes. + assert job.status_notes == [ + { + "type": "CountOfUsersAddedToGroup", + "value": "5", + "text": "Description of how many users were added to the group during the import.", + } + ] + + +def test_status_notes_empty_when_absent() -> None: + # A job element with no yields an empty list, not None or an error. + xml = ( + b"" + b"" + b"" + ) + jobs = TSC.JobItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(jobs) == 1 + assert jobs[0].status_notes == [] + + +def test_status_notes_multiple_entries() -> None: + # Multiple statusNote elements yield an ordered list of dicts. Any of type / + # value / text may be absent on a given note; missing attributes come back as None. + xml = ( + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + b"" + ) + jobs = TSC.JobItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(jobs) == 1 + notes = jobs[0].status_notes + assert notes == [ + {"type": "line", "value": "0", "text": None}, + {"type": "errorCode", "value": "1", "text": None}, + {"type": "message", "value": "Actor does not have permission", "text": None}, + {"type": "username", "value": "unknown", "text": None}, + ] def test_get_before_signin(server: TSC.Server) -> None: