diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1430d38..5e3985bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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* diff --git a/samples/create_extract_refresh_subscription.py b/samples/create_extract_refresh_subscription.py new file mode 100644 index 000000000..ae032a0bd --- /dev/null +++ b/samples/create_extract_refresh_subscription.py @@ -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() diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 9ae99e398..17fcc3dff 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -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 @@ -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: @@ -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) @@ -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) @@ -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 diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index d69424e44..8bfd71b6f 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -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) @@ -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 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) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index fc4694c01..91acbd218 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -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") @@ -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 @@ -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") diff --git a/test/test_subscription.py b/test/test_subscription.py index 7c78cc57d..991d67b84 100644 --- a/test/test_subscription.py +++ b/test/test_subscription.py @@ -100,3 +100,196 @@ def test_delete_subscription(server: TSC.Server) -> None: with requests_mock.mock() as m: m.delete(server.subscriptions.baseurl + "/78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", status_code=204) server.subscriptions.delete("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc") + + +# ----------------------------------------------------------------- +# refresh_extract_triggered (aka "On Extract Refresh" subscriptions) +# ----------------------------------------------------------------- + + +def test_create_rejects_none_schedule_id(server: TSC.Server) -> None: + """Regression for tableau/server-client-python#1658: users trying to create + an 'On Extract Refresh' subscription would pass schedule_id=None. Point them + at on_extract_refresh() instead of letting the failure surface deep in + the wire layer as a ServerResponseError. The check lives in create() (not + __init__) so parse can still build items from server responses that use + the inline-schedule form (no schedule id on the wire). + """ + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", None, "user-id", target) + with pytest.raises(ValueError, match="on_extract_refresh"): + server.subscriptions.create(sub) + + +def test_create_rejects_empty_schedule_id(server: TSC.Server) -> None: + """Same failure mode as None: an empty-string schedule_id would serialize + as and hit a confusing server error. + """ + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "", "user-id", target) + with pytest.raises(ValueError, match="on_extract_refresh"): + server.subscriptions.create(sub) + + +def test_subscription_defaults_refresh_extract_triggered_false(server: TSC.Server) -> None: + """A default SubscriptionItem does not opt into extract-refresh triggering.""" + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) + assert sub.refresh_extract_triggered is False + + +def test_on_extract_refresh_factory_sets_flag(server: TSC.Server) -> None: + """The on_extract_refresh factory produces a subscription with the flag set + and the extract-refresh schedule id in place -- server rejects a payload + that has the flag without a schedule reference, so both must be set together. + """ + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem.on_extract_refresh( + subject="On refresh", + extract_refresh_schedule_id="refresh-sched-id", + user_id="user-id", + target=target, + ) + assert sub.refresh_extract_triggered is True + assert sub.schedule_id == "refresh-sched-id" + assert sub.subject == "On refresh" + assert sub.user_id == "user-id" + assert sub.target is target + + +def test_create_req_emits_refresh_extract_triggered_when_set(server: TSC.Server) -> None: + """When the flag is set, the outbound XML should carry + refreshExtractTriggered='true' on the element. + """ + from tableauserverclient.server.request_factory import RequestFactory + + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem.on_extract_refresh( + subject="On refresh", + extract_refresh_schedule_id="refresh-sched-id", + user_id="user-id", + target=target, + ) + body = RequestFactory.Subscription.create_req(sub).decode("utf-8") + assert 'refreshExtractTriggered="true"' in body + + +def test_create_req_omits_refresh_extract_triggered_when_false(server: TSC.Server) -> None: + """A default subscription must not emit refreshExtractTriggered=false. Some + servers treat absence and False differently; we send only when the caller + has explicitly opted in. + """ + from tableauserverclient.server.request_factory import RequestFactory + + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) + body = RequestFactory.Subscription.create_req(sub).decode("utf-8") + assert "refreshExtractTriggered" not in body + + +def test_parse_response_reads_refresh_extract_triggered(server: TSC.Server) -> None: + """A subscription XML element carrying refreshExtractTriggered='true' + parses back into refresh_extract_triggered=True on the SubscriptionItem. + """ + xml = ( + b'' + b" " + b' ' + b' ' + b' ' + b' ' + b" " + b" " + b"" + ) + subs = TSC.SubscriptionItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(subs) == 1 + assert subs[0].refresh_extract_triggered is True + assert subs[0].schedule_id == "refresh-sched-1" + + +def test_update_rejects_missing_schedule_id(server: TSC.Server) -> None: + """A subscription round-tripped from an inline-schedule response has + schedule_id=None. Calling update() on it would send with no id + and hit a confusing wire-layer error. Catch it at the endpoint instead. + """ + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) + sub._id = "existing-sub-id" # type: ignore[assignment] + sub.schedule_id = None + with pytest.raises(ValueError, match="schedule_id is required"): + server.subscriptions.update(sub) + + +def test_update_req_emits_refresh_extract_triggered_when_true(server: TSC.Server) -> None: + """When the flag is True, update_req must emit refreshExtractTriggered='true'.""" + from tableauserverclient.server.request_factory import RequestFactory + + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem.on_extract_refresh( + subject="On refresh", + extract_refresh_schedule_id="refresh-sched-id", + user_id="user-id", + target=target, + ) + body = RequestFactory.Subscription.update_req(sub).decode("utf-8") + assert 'refreshExtractTriggered="true"' in body + + +def test_update_req_emits_refresh_extract_triggered_when_false(server: TSC.Server) -> None: + """update_req must emit refreshExtractTriggered='false' so callers can turn + the flag off. The server retains the prior value when the attribute is + absent, so omission would silently prevent True -> False transitions. + """ + from tableauserverclient.server.request_factory import RequestFactory + + target = TSC.Target("view-id", "view") + sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target) + assert sub.refresh_extract_triggered is False + body = RequestFactory.Subscription.update_req(sub).decode("utf-8") + assert 'refreshExtractTriggered="false"' in body + + +def test_parse_response_with_inline_schedule_no_id(server: TSC.Server) -> None: + """Regression: on Cloud/TOL the server may return a element with + no id attribute (the full schedule is inlined instead). Parse must handle + this without raising -- the constructor cannot demand a schedule_id here. + """ + xml = ( + b'' + b" " + b' ' + b' ' + b' ' + b' ' + b" " + b' ' + b" " + b" " + b"" + ) + subs = TSC.SubscriptionItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(subs) == 1 + assert subs[0].schedule_id is None + assert subs[0].schedule is not None + + +def test_parse_response_missing_refresh_extract_triggered_defaults_false(server: TSC.Server) -> None: + """Backward compatibility: a subscription XML element without the attribute + parses back to refresh_extract_triggered=False. + """ + xml = ( + b'' + b" " + b' ' + b' ' + b' ' + b' ' + b" " + b" " + b"" + ) + subs = TSC.SubscriptionItem.from_response(xml, {"t": "http://tableau.com/api"}) + assert len(subs) == 1 + assert subs[0].refresh_extract_triggered is False