Skip to content
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<statusNotes><statusNote
type=".." value=".." text=".."/></statusNotes>` 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 `<notes>` element still emitted by
some job types). Fixes #1850.

## 0.18.0 (6 April 2022)
* Switched to using defused_xml for xml attack protection
Expand Down
29 changes: 28 additions & 1 deletion tableauserverclient/models/job_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<extractRefreshJob><notes>...</notes></extractRefreshJob>`.
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 (`<statusNotes><statusNote type="..." value="..." text="..." /></statusNotes>`).
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

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -253,6 +279,7 @@ def _parse_element(cls, element, ns):
updated_at,
workbook_name,
datasource_name,
status_notes,
)


Expand Down
47 changes: 47 additions & 0 deletions test/test_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <statusNotes><statusNote .../></statusNotes>
# 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 <statusNotes> yields an empty list, not None or an error.
xml = (
b"<tsResponse xmlns='http://tableau.com/api'>"
b"<job id='j1' type='extractRefreshJob' progress='100' createdAt='2020-05-13T20:23:45Z' finishCode='0'/>"
b"</tsResponse>"
)
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"<tsResponse xmlns='http://tableau.com/api'>"
b"<job id='j1' type='UserImport' progress='100' createdAt='2020-05-13T20:23:45Z' finishCode='1'>"
b"<statusNotes>"
b"<statusNote type='line' value='0'/>"
b"<statusNote type='errorCode' value='1'/>"
b"<statusNote type='message' value='Actor does not have permission'/>"
b"<statusNote type='username' value='unknown'/>"
b"</statusNotes>"
b"</job>"
b"</tsResponse>"
)
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:
Expand Down
Loading