Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@

## Unreleased

* Added support for "On Extract Refresh" subscriptions. These are Tableau
Cloud subscriptions that fire when a referenced extract-refresh schedule
completes, rather than on a time trigger, so recipients always get the
freshest data. New `SubscriptionItem.on_extract_refresh(subject,
extract_refresh_schedule_id, user_id, target)` classmethod is the
recommended way to construct them, and `SubscriptionItem.refresh_extract_triggered`
is a boolean property that reflects the `refreshExtractTriggered`
attribute on the wire. `subscriptions.create()` and `.update()` now
raise `ValueError` if `schedule_id` is missing (previously a confusing
server-side error). Fixes #1658.
* Added `Projects.get_by_path(path)` to look up a project by its slash-separated
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*
Expand Down
108 changes: 108 additions & 0 deletions samples/create_extract_refresh_subscription.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
####
# This script creates a Tableau Cloud "On Extract Refresh" subscription:
# a subscription that fires when an extract-refresh schedule completes,
# rather than on the schedule's time trigger. Recipients get the email
# alongside the refresh, so they always see the freshest data.
#
# What it does:
# 1. Sign in.
# 2. Look up the target view or workbook by name.
# 3. List extract-refresh schedules and pick the one you named.
# 4. Build the subscription via SubscriptionItem.on_extract_refresh().
# 5. Call subscriptions.create() and print the new subscription id.
#
# On Tableau Server this same script works as long as the schedule you
# reference is an extract-refresh schedule; the "On Extract Refresh"
# terminology is Cloud-UI-specific but the REST attribute
# (refreshExtractTriggered) is the same on both.
#
# Requires Python 3.10 or later.
####


import argparse
import logging

import tableauserverclient as TSC


def usage(args):
parser = argparse.ArgumentParser(description="Create an On Extract Refresh subscription for a view or workbook.")
# Common options; keep in sync across samples.
parser.add_argument("--server", "-s", required=True, help="server address")
parser.add_argument("--site", "-S", default="", help="site content URL")
parser.add_argument("--token-name", "-p", required=True, help="personal access token name")
parser.add_argument("--token-value", "-v", required=True, help="personal access token value")
parser.add_argument(
"--logging-level",
"-l",
choices=["debug", "info", "error"],
default="error",
)
# Sample-specific options.
parser.add_argument("--subject", required=True, help="subscription subject line")
parser.add_argument("--schedule", required=True, help="name of the extract-refresh schedule to attach to")
parser.add_argument("--user", required=True, help="username of the subscription recipient")
target = parser.add_mutually_exclusive_group(required=True)
target.add_argument("--view", help="name of the view to send")
target.add_argument("--workbook", help="name of the workbook to send")
return parser.parse_args(args)


def _find_one(items, label, name):
matches = [i for i in items if i.name == name]
if len(matches) != 1:
raise SystemExit(f"expected exactly one {label} named {name!r}, found {len(matches)}")
return matches[0]


def find_extract_refresh_schedule(server, name):
schedules = [s for s in TSC.Pager(server.schedules) if s.schedule_type == TSC.ScheduleItem.Type.Extract]
return _find_one(schedules, "extract-refresh schedule", name)


def find_view(server, name):
return _find_one(list(TSC.Pager(server.views)), "view", name)


def find_workbook(server, name):
return _find_one(list(TSC.Pager(server.workbooks)), "workbook", name)


def find_user(server, name):
return _find_one(list(TSC.Pager(server.users)), "user", name)


def run(args):
logging.basicConfig(level=getattr(logging, args.logging_level.upper()))

auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(auth):
schedule = find_extract_refresh_schedule(server, args.schedule)
user = find_user(server, args.user)
if args.view:
content = find_view(server, args.view)
target = TSC.Target(content.id, "view")
else:
content = find_workbook(server, args.workbook)
target = TSC.Target(content.id, "workbook")

subscription = TSC.SubscriptionItem.on_extract_refresh(
subject=args.subject,
extract_refresh_schedule_id=schedule.id,
user_id=user.id,
target=target,
)
created = server.subscriptions.create(subscription)
print(f"Created subscription {created.id}: {created.subject!r} on schedule {schedule.name!r}")


def main():
import sys

run(usage(sys.argv[1:]))


if __name__ == "__main__":
main()
141 changes: 140 additions & 1 deletion tableauserverclient/models/subscription_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,51 @@


class SubscriptionItem:
def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None:
"""A subscription that sends a view or workbook to a user on a schedule.

Subscriptions fire on one of two triggers:

1. **Time-based** (the common case): the referenced schedule's time
trigger runs -- e.g. a "Weekly Monday 8am" schedule fires the
subscription every Monday at 8am. Construct these with the normal
``SubscriptionItem(subject, schedule_id, user_id, target)`` form.

2. **Extract-refresh-triggered**: the referenced schedule's extract
refresh completes -- the subscription fires alongside the refresh,
so recipients always get the freshest data. Use the
:meth:`on_extract_refresh` classmethod to construct these; it sets
:attr:`refresh_extract_triggered` to ``True`` for you.

In the Cloud web UI, extract-refresh-triggered subscriptions show up
as schedule "On Extract Refresh". At the REST API level there is no
"On Extract Refresh" schedule type; instead the subscription
references an existing extract-refresh schedule *and* sets
``refreshExtractTriggered=true`` on the payload.

Examples
--------
Time-based subscription:

>>> sub = TSC.SubscriptionItem(
... subject="Weekly report",
... schedule_id=weekly_schedule.id,
... user_id=user.id,
... target=TSC.Target(view.id, "view"),
... )
>>> server.subscriptions.create(sub)

Extract-refresh-triggered subscription:

>>> sub = TSC.SubscriptionItem.on_extract_refresh(
... subject="Send when refresh finishes",
... extract_refresh_schedule_id=nightly_refresh_schedule.id,
... user_id=user.id,
... target=TSC.Target(view.id, "view"),
... )
>>> server.subscriptions.create(sub)
"""

def __init__(self, subject: str, schedule_id: str | None, user_id: str, target: "Target") -> None:
self._id = None
self.attach_image = True
self.attach_pdf = False
Expand All @@ -25,6 +69,56 @@ def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target
self.target = target
self.user_id = user_id
self.schedule = None
self._refresh_extract_triggered: bool = False

@classmethod
def on_extract_refresh(
cls,
subject: str,
extract_refresh_schedule_id: str,
user_id: str,
target: "Target",
) -> "SubscriptionItem":
"""Construct a subscription that fires when an extract refresh runs.

The subscription references an existing extract-refresh schedule and
will fire alongside that schedule's extract refresh, so recipients
get the freshest data. Server-side this maps to
``refreshExtractTriggered=true`` on the subscription entity; the Cloud
UI surfaces the same state as schedule type "On Extract Refresh".

Parameters
----------
subject : str
Subscription subject line, shown in the delivered email.
extract_refresh_schedule_id : str
ID of an existing schedule that owns an extract refresh. On Cloud
list schedules with ``server.schedules.get()`` and filter to the
extract-refresh schedules; on-prem the same list is populated by
the site's server-authored schedules.
user_id : str
ID of the recipient user.
target : Target
The workbook or view to send.

Returns
-------
SubscriptionItem
A subscription with ``refresh_extract_triggered`` set to True.
Pass to ``server.subscriptions.create(...)`` to create it.

Notes
-----
This factory does not validate that ``extract_refresh_schedule_id``
actually references an extract-refresh schedule. Referencing a
non-extract schedule with ``refresh_extract_triggered=True`` is a
server-side error and will surface when ``create()`` is called.

Related to tableau/server-client-python#1658.
"""
sub = cls(subject, extract_refresh_schedule_id, user_id, target)
sub.refresh_extract_triggered = True
return sub

def __repr__(self) -> str:
if self.id is not None:
Expand Down Expand Up @@ -74,6 +168,49 @@ def suspended(self) -> bool:
def suspended(self, value: bool) -> None:
self._suspended = value

@property
def refresh_extract_triggered(self) -> bool:
"""Whether this subscription fires when its schedule's extract refresh runs.

When True, the subscription must reference an existing extract-refresh
schedule (via ``schedule_id``) and will fire alongside that schedule's
extract refresh. When False (the default), the subscription fires on
the schedule's time trigger like every other subscription.

The Cloud web UI surfaces the True state as schedule type "On Extract
Refresh"; there is no such REST-API schedule type, so callers must set
this flag explicitly. Prefer :meth:`on_extract_refresh` when
constructing new extract-refresh-triggered subscriptions -- it wires
up ``schedule_id`` and this flag together in one call.

Setting this to True on a subscription that references a non-extract
schedule (Subscription, Flow, System, etc.) is a server-side error;
the ``create()``/``update()`` call will raise. TSC does not fetch the
referenced schedule to validate this client-side.

**Updating an existing subscription:** if an update changes the
referenced schedule, the server silently forces this flag back to
False on that same call, regardless of what the client sent. To
convert a time-based subscription into an extract-refresh-triggered
one, issue two updates: first change ``schedule_id``, then set
``refresh_extract_triggered = True`` on a second call.

**Manual-build update() footgun:** every ``subscriptions.update()``
payload now carries ``refreshExtractTriggered="true"`` or
``"false"``. The safe pattern is fetch-then-mutate-then-update, so
the value round-trips through the parser. If instead you build a
fresh ``SubscriptionItem`` locally, assign ``_id`` yourself, and
call ``update()``, the default False on the new item will flip an
existing extract-refresh-triggered subscription off on the server.
Fetch first.
"""
return self._refresh_extract_triggered

@refresh_extract_triggered.setter
@property_is_boolean
def refresh_extract_triggered(self, value: bool) -> None:
self._refresh_extract_triggered = value

@classmethod
def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]:
parsed_response = fromstring(xml)
Expand Down Expand Up @@ -119,6 +256,7 @@ def _parse_element(cls, element, ns):
page_orientation = element.get("pageOrientation", None)
page_size_option = element.get("pageSizeOption", None)
suspended = string_to_bool(element.get("suspended", ""))
refresh_extract_triggered = string_to_bool(element.get("refreshExtractTriggered", ""))

# Create SubscriptionItem and set fields
sub = cls(subject, schedule_id, user_id, target)
Expand All @@ -131,6 +269,7 @@ def _parse_element(cls, element, ns):
sub.send_if_view_empty = send_if_view_empty
sub.suspended = suspended
sub.schedule = schedule
sub.refresh_extract_triggered = refresh_extract_triggered

return sub

Expand Down
14 changes: 14 additions & 0 deletions tableauserverclient/server/endpoint/subscriptions_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem:
if not subscription_item:
error = "No Susbcription provided"
raise ValueError(error)
if not subscription_item.schedule_id:
# See tableau/server-client-python#1658: users trying to create an
# "On Extract Refresh" subscription pass schedule_id=None and hit
# a confusing wire-layer error. Point them at the factory.
raise ValueError(
"schedule_id is required; for on-extract-refresh subscriptions "
"use SubscriptionItem.on_extract_refresh(...)"
)
logger.info(f"Creating a subscription ({subscription_item})")
url = self.baseurl
create_req = RequestFactory.Subscription.create_req(subscription_item)
Expand All @@ -63,6 +71,12 @@ def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem:
if not subscription_item.id:
error = "Subscription item missing ID. Subscription must be retrieved from server first."
raise MissingRequiredFieldError(error)
if not subscription_item.schedule_id:
# A subscription round-tripped from an inline-schedule response
# (Cloud/TOL) has schedule_id=None. Updating it in that state
# sends <schedule/> with no id and hits the same wire-layer error
# that create() guards against. See tableau/server-client-python#1658.
raise ValueError("schedule_id is required to update a subscription")
url = f"{self.baseurl}/{subscription_item.id}"
update_req = RequestFactory.Subscription.update_req(subscription_item)
server_response = self.put_request(url, update_req)
Expand Down
16 changes: 15 additions & 1 deletion tableauserverclient/server/request_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,13 @@ def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt
subscription_element.attrib["pageOrientation"] = subscription_item.page_orientation
if subscription_item.page_size_option is not None:
subscription_element.attrib["pageSizeOption"] = subscription_item.page_size_option
# On create, only emit refreshExtractTriggered when True -- server default
# is False, and emitting the attribute unconditionally would surface as a
# payload change on servers that treat absence differently from an explicit
# False. update_req is asymmetric here: it must emit False to enable the
# True -> False transition on an existing subscription.
if subscription_item.refresh_extract_triggered:
subscription_element.attrib["refreshExtractTriggered"] = "true"

# Content element
content_element = ET.SubElement(subscription_element, "content")
Expand All @@ -1342,7 +1349,10 @@ def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt
if subscription_item.send_if_view_empty is not None:
content_element.attrib["sendIfViewEmpty"] = str(subscription_item.send_if_view_empty).lower()

# Schedule element
# Schedule element. schedule_id can be None on items parsed from
# inline-schedule responses; subscriptions.create() guards against
# that before we get here, so the value is non-None at this point.
assert subscription_item.schedule_id is not None
schedule_element = ET.SubElement(subscription_element, "schedule")
schedule_element.attrib["id"] = subscription_item.schedule_id

Expand All @@ -1368,6 +1378,10 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt
subscription.attrib["pageSizeOption"] = subscription_item.page_size_option
if subscription_item.suspended is not None:
subscription.attrib["suspended"] = str(subscription_item.suspended).lower()
# update_req always emits the flag so callers can turn it off. The
# server retains the prior value when the attribute is absent, so
# omission would silently prevent True -> False transitions.
subscription.attrib["refreshExtractTriggered"] = str(subscription_item.refresh_extract_triggered).lower()

# Schedule element
schedule = ET.SubElement(subscription, "schedule")
Expand Down
Loading
Loading