Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
130 changes: 130 additions & 0 deletions tableauserverclient/models/subscription_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,50 @@


class SubscriptionItem:
"""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, user_id: str, target: "Target") -> None:
self._id = None
self.attach_image = True
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,40 @@ 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.
"""
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 +247,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 +260,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
11 changes: 11 additions & 0 deletions tableauserverclient/server/endpoint/subscriptions_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ 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; see SubscriptionItem.on_extract_refresh")
Comment thread
jacalata marked this conversation as resolved.
Outdated
logger.info(f"Creating a subscription ({subscription_item})")
url = self.baseurl
create_req = RequestFactory.Subscription.create_req(subscription_item)
Expand All @@ -63,6 +68,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
11 changes: 11 additions & 0 deletions 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 Down Expand Up @@ -1368,6 +1375,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