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: