From 4469af167305a0d301580146f8441c5ed9a583fb Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Wed, 11 Nov 2026 20:24:37 +0530 Subject: [PATCH] Add Recycle Bin endpoint (server.recycle_bin) Wrap the five Recycle Bin REST methods introduced in API 3.29: - get(content_type, req_options): list deleted workbooks, datasources, flows or projects, with filter/sort/paging through RequestOptions - get_by_id(content_type, content_id) - restore(item, project, content_type=None): POST .../restore?projectId= - delete(item, content_type=None): permanently delete one item - empty(as_job=False): delete everything, optionally as an EmptyRecycleBin job returned as a JobItem Add RecycleBinItem (id, name, content_type, created/updated times, original_path, is_read_only, owner_id, deleter_id) and export it from the package. Content types accept the plural URL form or the singular form the server reports on an item, so a RecycleBinItem can be passed straight to restore()/delete(). Add the DeleterId filter field used by the list method. Fixes #1896 Signed-off-by: Asish Kumar --- tableauserverclient/__init__.py | 2 + tableauserverclient/models/__init__.py | 2 + .../models/recycle_bin_item.py | 99 +++++++++ tableauserverclient/server/__init__.py | 2 + .../server/endpoint/__init__.py | 2 + .../server/endpoint/recycle_bin_endpoint.py | 200 ++++++++++++++++++ tableauserverclient/server/request_options.py | 1 + tableauserverclient/server/server.py | 2 + test/assets/recycle_bin_empty_job.xml | 4 + test/assets/recycle_bin_get.xml | 14 ++ test/assets/recycle_bin_get_by_id.xml | 9 + test/test_recycle_bin.py | 168 +++++++++++++++ 12 files changed, 505 insertions(+) create mode 100644 tableauserverclient/models/recycle_bin_item.py create mode 100644 tableauserverclient/server/endpoint/recycle_bin_endpoint.py create mode 100644 test/assets/recycle_bin_empty_job.xml create mode 100644 test/assets/recycle_bin_get.xml create mode 100644 test/assets/recycle_bin_get_by_id.xml create mode 100644 test/test_recycle_bin.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 819cfcfa6..a0771935c 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -39,6 +39,7 @@ PermissionsRule, PersonalAccessTokenAuth, ProjectItem, + RecycleBinItem, Resource, RevisionItem, SafeExtension, @@ -137,6 +138,7 @@ "PermissionsRule", "PersonalAccessTokenAuth", "ProjectItem", + "RecycleBinItem", "RequestOptions", "Resource", "RevisionItem", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 5eb79f8e8..3e91fa8c7 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -41,6 +41,7 @@ from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.models.permissions_item import PermissionsRule, Permission from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.recycle_bin_item import RecycleBinItem from tableauserverclient.models.revision_item import RevisionItem from tableauserverclient.models.schedule_item import ScheduleItem from tableauserverclient.models.server_info_item import ServerInfoItem @@ -96,6 +97,7 @@ "Permission", "PermissionsRule", "ProjectItem", + "RecycleBinItem", "RevisionItem", "ScheduleItem", "ServerInfoItem", diff --git a/tableauserverclient/models/recycle_bin_item.py b/tableauserverclient/models/recycle_bin_item.py new file mode 100644 index 000000000..e60dd886a --- /dev/null +++ b/tableauserverclient/models/recycle_bin_item.py @@ -0,0 +1,99 @@ +import datetime +import xml.etree.ElementTree as ET + +from defusedxml.ElementTree import fromstring + +from tableauserverclient.datetime_helpers import parse_datetime + + +class RecycleBinItem: + """ + An item of content (workbook, data source, flow or project) that has been + deleted and is held in the site's recycle bin. + + Parameters + ---------- + id_ : str | None + The identifier of the deleted content. + + name : str | None + The name of the deleted content. + + content_type : str | None + The type of the deleted content, as reported by the server: one of + "workbook", "datasource", "flow" or "project". + + created_at : datetime.datetime | None + When the content was originally created. + + updated_at : datetime.datetime | None + When the content was last updated before it was deleted. + + original_path : str | None + The project path the content was in before it was deleted. + + is_read_only : bool | None + Whether the content is read only. + + owner_id : str | None + The identifier of the user who owned the content. + + deleter_id : str | None + The identifier of the user who deleted the content. + """ + + tag_name: str = "contentItem" + + def __init__( + self, + id_: str | None = None, + name: str | None = None, + content_type: str | None = None, + created_at: datetime.datetime | None = None, + updated_at: datetime.datetime | None = None, + original_path: str | None = None, + is_read_only: bool | None = None, + owner_id: str | None = None, + deleter_id: str | None = None, + ) -> None: + self._id = id_ + self.name = name + self.content_type = content_type + self.created_at = created_at + self.updated_at = updated_at + self.original_path = original_path + self.is_read_only = is_read_only + self.owner_id = owner_id + self.deleter_id = deleter_id + + def __repr__(self) -> str: + return ( + f"<{self.__class__.__qualname__} id={self._id} name={self.name} " + f"content_type={self.content_type} original_path={self.original_path}>" + ) + + @property + def id(self) -> str | None: + return self._id + + @classmethod + def from_response(cls, resp: bytes, ns: dict[str, str]) -> list["RecycleBinItem"]: + parsed_response = fromstring(resp) + return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:contentItem", namespaces=ns)] + + @classmethod + def from_xml(cls, xml: ET.Element, ns: dict[str, str]) -> "RecycleBinItem": + is_read_only = xml.get("isReadOnly") + owner = xml.find("t:owner", namespaces=ns) + deleter = xml.find("t:deleter", namespaces=ns) + return cls( + id_=xml.get("id"), + name=xml.get("name"), + content_type=xml.get("contentType"), + created_at=parse_datetime(xml.get("createdAt")), + updated_at=parse_datetime(xml.get("updatedAt")), + original_path=xml.get("originalPath"), + is_read_only=None if is_read_only is None else is_read_only.lower() == "true", + owner_id=owner.get("id") if owner is not None else None, + deleter_id=deleter.get("id") if deleter is not None else None, + ) diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 44020419b..154015f50 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -41,6 +41,7 @@ Metadata, Metrics, Projects, + RecycleBin, Schedules, ServerInfo, ServerResponseError, @@ -91,6 +92,7 @@ "Metadata", "Metrics", "Projects", + "RecycleBin", "Schedules", "ServerInfo", "ServerResponseError", diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index de2f7e8a1..e7bd5c0fc 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -21,6 +21,7 @@ from tableauserverclient.server.endpoint.metrics_endpoint import Metrics from tableauserverclient.server.endpoint.oidc_endpoint import OIDC from tableauserverclient.server.endpoint.projects_endpoint import Projects +from tableauserverclient.server.endpoint.recycle_bin_endpoint import RecycleBin from tableauserverclient.server.endpoint.schedules_endpoint import Schedules from tableauserverclient.server.endpoint.server_info_endpoint import ServerInfo from tableauserverclient.server.endpoint.sites_endpoint import Sites @@ -60,6 +61,7 @@ "Metrics", "OIDC", "Projects", + "RecycleBin", "Schedules", "ServerInfo", "ServerResponseError", diff --git a/tableauserverclient/server/endpoint/recycle_bin_endpoint.py b/tableauserverclient/server/endpoint/recycle_bin_endpoint.py new file mode 100644 index 000000000..5fa8b6eb2 --- /dev/null +++ b/tableauserverclient/server/endpoint/recycle_bin_endpoint.py @@ -0,0 +1,200 @@ +from typing import Literal, TypeAlias, TYPE_CHECKING, overload + +from tableauserverclient.helpers.logging import logger +from tableauserverclient.models.job_item import JobItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.recycle_bin_item import RecycleBinItem +from tableauserverclient.server.endpoint.endpoint import Endpoint, api +from tableauserverclient.server.request_options import RequestOptions + +if TYPE_CHECKING: + from tableauserverclient.server.server import Server + +RecycleBinContentType: TypeAlias = Literal["workbooks", "datasources", "flows", "projects"] + +_URL_SEGMENTS = {"workbook", "datasource", "flow", "project"} + + +class RecycleBin(Endpoint): + """ + The recycle bin holds workbooks, data sources, flows and projects that have + been deleted from a site, until they are restored or permanently deleted. + + Content types are given as one of "workbooks", "datasources", "flows" or + "projects". The singular form reported by the server on a RecycleBinItem + (for example "workbook") is accepted as well. + """ + + def __init__(self, parent_srv: "Server") -> None: + super().__init__(parent_srv) + + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/recyclebin" + + @staticmethod + def _content_type_segment(content_type: str) -> str: + singular = content_type[:-1] if content_type.endswith("s") else content_type + if singular not in _URL_SEGMENTS: + raise ValueError( + f"Unknown recycle bin content type '{content_type}'. " + "Expected one of: workbooks, datasources, flows, projects." + ) + return f"{singular}s" + + def _item_url(self, content_type: str | None, item: "RecycleBinItem | str") -> str: + if isinstance(item, RecycleBinItem): + if not item.id: + raise ValueError("The recycle bin item must have an id.") + content_type = content_type or item.content_type + item_id = item.id + else: + item_id = item + if not content_type: + raise ValueError("content_type is required when the item is given as an id.") + return f"{self.baseurl}/{self._content_type_segment(content_type)}/{item_id}" + + @api(version="3.29") + def get( + self, content_type: RecycleBinContentType, req_options: RequestOptions | None = None + ) -> tuple[list[RecycleBinItem], PaginationItem]: + """ + List the deleted content of one type in the recycle bin. Supports + filtering on name, ownerId, deleterId, createdAt, updatedAt and path, + and sorting and paging through RequestOptions. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_recycle_bin.htm#list_recycle_bin_content_by_type + + Parameters + ---------- + content_type : "workbooks" | "datasources" | "flows" | "projects" + The type of content to list. + + req_options : RequestOptions, optional + Filter, sort and paging options. + + Returns + ------- + tuple[list[RecycleBinItem], PaginationItem] + """ + logger.info(f"Querying the recycle bin for {content_type}") + url = f"{self.baseurl}/{self._content_type_segment(content_type)}" + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) + items = RecycleBinItem.from_response(server_response.content, self.parent_srv.namespace) + return items, pagination_item + + @api(version="3.29") + def get_by_id(self, content_type: RecycleBinContentType, content_id: str) -> RecycleBinItem: + """ + Get a single deleted item from the recycle bin. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_recycle_bin.htm#get_recycle_bin_content_item + + Parameters + ---------- + content_type : "workbooks" | "datasources" | "flows" | "projects" + The type of the content. + + content_id : str + The identifier of the deleted content. + + Returns + ------- + RecycleBinItem + """ + if not content_id: + raise ValueError("content_id is required.") + logger.info(f"Querying the recycle bin for {content_type} (ID: {content_id})") + url = self._item_url(content_type, content_id) + server_response = self.get_request(url) + return RecycleBinItem.from_response(server_response.content, self.parent_srv.namespace)[0] + + @api(version="3.29") + def restore( + self, + item: RecycleBinItem | str, + project: ProjectItem | str, + content_type: RecycleBinContentType | None = None, + ) -> None: + """ + Restore a deleted item from the recycle bin into a project. The caller + needs Publish permission on the destination project. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_recycle_bin.htm#restore_recycle_bin_content_item + + Parameters + ---------- + item : RecycleBinItem | str + The item to restore, or its id. When an id is given, content_type + is required. + + project : ProjectItem | str + The project, or project id, to restore the content into. + + content_type : "workbooks" | "datasources" | "flows" | "projects", optional + The type of the content. Defaults to the type of a RecycleBinItem. + """ + project_id = project.id if isinstance(project, ProjectItem) else project + if not project_id: + raise ValueError("A destination project id is required to restore content.") + url = f"{self._item_url(content_type, item)}/restore" + logger.info(f"Restoring recycle bin content to project (ID: {project_id}): {url}") + self.post_request(url, "", parameters={"params": {"projectId": project_id}}) + + @api(version="3.29") + def delete(self, item: RecycleBinItem | str, content_type: RecycleBinContentType | None = None) -> None: + """ + Permanently delete one item from the recycle bin. This cannot be undone. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_recycle_bin.htm#delete_recycle_bin_content_item + + Parameters + ---------- + item : RecycleBinItem | str + The item to delete, or its id. When an id is given, content_type + is required. + + content_type : "workbooks" | "datasources" | "flows" | "projects", optional + The type of the content. Defaults to the type of a RecycleBinItem. + """ + url = self._item_url(content_type, item) + logger.info(f"Permanently deleting recycle bin content: {url}") + self.delete_request(url) + + @overload + def empty(self, as_job: Literal[False] = False) -> None: ... + + @overload + def empty(self, as_job: Literal[True]) -> JobItem: ... + + @api(version="3.29") + def empty(self, as_job: bool = False) -> JobItem | None: + """ + Permanently delete everything in the recycle bin. This cannot be undone. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_recycle_bin.htm#delete_all_recycle_bin_contents + + Parameters + ---------- + as_job : bool, default False + Run the deletion as an asynchronous EmptyRecycleBin job and return + the JobItem, which can be passed to `server.jobs.wait_for_job`. + + Returns + ------- + JobItem | None + The job when as_job is True, otherwise None. + """ + logger.info("Emptying the recycle bin") + if not as_job: + self.delete_request(self.baseurl) + return None + server_response = self._make_request( + self.parent_srv.session.delete, + self.baseurl, + auth_token=self.parent_srv.auth_token, + parameters={"params": {"asJob": "true"}}, + ) + return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 14fb18c61..5ec711c5e 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -133,6 +133,7 @@ class Field: CreatedAt = "createdAt" DatabaseName = "databaseName" DatabaseUserName = "databaseUserName" + DeleterId = "deleterId" Description = "description" DisplayTabs = "displayTabs" DomainName = "domainName" diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 853bdea68..0a554c344 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -18,6 +18,7 @@ Workbooks, Datasources, Projects, + RecycleBin, Auth, AuthFederation, Schedules, @@ -235,6 +236,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.oidc = OIDC(self) self.auth_federation = AuthFederation(self) self.extensions = Extensions(self) + self.recycle_bin = RecycleBin(self) self._http_options = dict() # must set this before making a server call if http_options: diff --git a/test/assets/recycle_bin_empty_job.xml b/test/assets/recycle_bin_empty_job.xml new file mode 100644 index 000000000..17cf8304b --- /dev/null +++ b/test/assets/recycle_bin_empty_job.xml @@ -0,0 +1,4 @@ + + + + diff --git a/test/assets/recycle_bin_get.xml b/test/assets/recycle_bin_get.xml new file mode 100644 index 000000000..e0fab5131 --- /dev/null +++ b/test/assets/recycle_bin_get.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/test/assets/recycle_bin_get_by_id.xml b/test/assets/recycle_bin_get_by_id.xml new file mode 100644 index 000000000..00bfc12af --- /dev/null +++ b/test/assets/recycle_bin_get_by_id.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/test/test_recycle_bin.py b/test/test_recycle_bin.py new file mode 100644 index 000000000..959be13c1 --- /dev/null +++ b/test/test_recycle_bin.py @@ -0,0 +1,168 @@ +import datetime +from pathlib import Path + +import pytest +import requests_mock + +import tableauserverclient as TSC +from tableauserverclient.server.exceptions import EndpointUnavailableError + +TEST_ASSET_DIR = Path(__file__).parent / "assets" +RECYCLE_BIN_GET = TEST_ASSET_DIR / "recycle_bin_get.xml" +RECYCLE_BIN_GET_BY_ID = TEST_ASSET_DIR / "recycle_bin_get_by_id.xml" +RECYCLE_BIN_EMPTY_JOB = TEST_ASSET_DIR / "recycle_bin_empty_job.xml" + +ITEM_ID = "a1b2c3d4-0000-4000-8000-000000000001" +PROJECT_ID = "44444444-4444-4444-8444-444444444444" + + +@pytest.fixture(scope="function") +def server(): + server = TSC.Server("http://test", False) + + # Fake signin + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + server.version = "3.29" + + return server + + +def test_baseurl(server: TSC.Server) -> None: + assert server.recycle_bin.baseurl == f"{server.baseurl}/sites/{server.site_id}/recyclebin" + + +def test_requires_api_3_29(server: TSC.Server) -> None: + server.version = "3.28" + with pytest.raises(EndpointUnavailableError): + server.recycle_bin.get("workbooks") + + +def test_get(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(f"{server.recycle_bin.baseurl}/workbooks", text=RECYCLE_BIN_GET.read_text()) + items, pagination = server.recycle_bin.get("workbooks") + + assert pagination.total_available == 2 + assert len(items) == 2 + first = items[0] + assert first.id == ITEM_ID + assert first.name == "Sales Dashboard" + assert first.content_type == "workbook" + assert first.original_path == "Finance/Reports" + assert first.is_read_only is False + assert first.owner_id == "11111111-1111-4111-8111-111111111111" + assert first.deleter_id == "22222222-2222-4222-8222-222222222222" + assert first.created_at == datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) + assert first.updated_at == datetime.datetime(2026, 2, 3, 4, 5, 6, tzinfo=datetime.timezone.utc) + assert items[1].is_read_only is True + + +def test_get_with_request_options(server: TSC.Server) -> None: + options = TSC.RequestOptions(pagesize=5) + options.filter.add(TSC.Filter(TSC.RequestOptions.Field.DeleterId, TSC.RequestOptions.Operator.Equals, "22222222")) + with requests_mock.mock() as m: + m.get(f"{server.recycle_bin.baseurl}/flows", text=RECYCLE_BIN_GET.read_text()) + server.recycle_bin.get("flows", options) + + assert m.last_request.qs["pagesize"] == ["5"] + assert m.last_request.qs["filter"] == ["deleterid:eq:22222222"] + + +@pytest.mark.parametrize("content_type", ["datasources", "datasource", "projects", "flows", "workbook"]) +def test_get_accepts_plural_and_singular_content_types(server: TSC.Server, content_type: str) -> None: + expected = content_type if content_type.endswith("s") else f"{content_type}s" + with requests_mock.mock() as m: + m.get(f"{server.recycle_bin.baseurl}/{expected}", text=RECYCLE_BIN_GET.read_text()) + items, _ = server.recycle_bin.get(content_type) # type: ignore[arg-type] + assert len(items) == 2 + + +def test_get_rejects_unknown_content_type(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.recycle_bin.get("views") # type: ignore[arg-type] + + +def test_get_by_id(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(f"{server.recycle_bin.baseurl}/workbooks/{ITEM_ID}", text=RECYCLE_BIN_GET_BY_ID.read_text()) + item = server.recycle_bin.get_by_id("workbooks", ITEM_ID) + + assert item.id == ITEM_ID + assert item.name == "Sales Dashboard" + + +def test_get_by_id_requires_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.recycle_bin.get_by_id("workbooks", "") + + +def test_restore_by_id(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.post(f"{server.recycle_bin.baseurl}/workbooks/{ITEM_ID}/restore", status_code=204) + server.recycle_bin.restore(ITEM_ID, PROJECT_ID, content_type="workbooks") + + assert m.last_request.qs["projectid"] == [PROJECT_ID] + + +def test_restore_item_uses_its_content_type_and_project_item(server: TSC.Server) -> None: + item = TSC.RecycleBinItem(id_=ITEM_ID, content_type="datasource") + project = TSC.ProjectItem("Restored") + project._id = PROJECT_ID + with requests_mock.mock() as m: + m.post(f"{server.recycle_bin.baseurl}/datasources/{ITEM_ID}/restore", status_code=204) + server.recycle_bin.restore(item, project) + + assert m.last_request.qs["projectid"] == [PROJECT_ID] + + +def test_restore_requires_content_type_for_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.recycle_bin.restore(ITEM_ID, PROJECT_ID) + + +def test_restore_requires_project(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.recycle_bin.restore(ITEM_ID, TSC.ProjectItem("No id"), content_type="workbooks") + + +def test_delete_by_id(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(f"{server.recycle_bin.baseurl}/workbooks/{ITEM_ID}", status_code=204) + server.recycle_bin.delete(ITEM_ID, content_type="workbooks") + + assert m.called + + +def test_delete_item(server: TSC.Server) -> None: + item = TSC.RecycleBinItem(id_=ITEM_ID, content_type="flow") + with requests_mock.mock() as m: + m.delete(f"{server.recycle_bin.baseurl}/flows/{ITEM_ID}", status_code=204) + server.recycle_bin.delete(item) + + assert m.called + + +def test_delete_requires_content_type_for_id(server: TSC.Server) -> None: + with pytest.raises(ValueError): + server.recycle_bin.delete(ITEM_ID) + + +def test_empty(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.recycle_bin.baseurl, status_code=204) + result = server.recycle_bin.empty() + + assert result is None + assert m.last_request.method == "DELETE" + assert "asjob" not in m.last_request.qs + + +def test_empty_as_job(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.delete(server.recycle_bin.baseurl, status_code=202, text=RECYCLE_BIN_EMPTY_JOB.read_text()) + job = server.recycle_bin.empty(as_job=True) + + assert m.last_request.qs["asjob"] == ["true"] + assert job.id == "33333333-3333-4333-8333-333333333333" + assert job.type == "EmptyRecycleBin"