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
2 changes: 2 additions & 0 deletions tableauserverclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
PermissionsRule,
PersonalAccessTokenAuth,
ProjectItem,
RecycleBinItem,
Resource,
RevisionItem,
SafeExtension,
Expand Down Expand Up @@ -137,6 +138,7 @@
"PermissionsRule",
"PersonalAccessTokenAuth",
"ProjectItem",
"RecycleBinItem",
"RequestOptions",
"Resource",
"RevisionItem",
Expand Down
2 changes: 2 additions & 0 deletions tableauserverclient/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -96,6 +97,7 @@
"Permission",
"PermissionsRule",
"ProjectItem",
"RecycleBinItem",
"RevisionItem",
"ScheduleItem",
"ServerInfoItem",
Expand Down
99 changes: 99 additions & 0 deletions tableauserverclient/models/recycle_bin_item.py
Original file line number Diff line number Diff line change
@@ -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,
)
2 changes: 2 additions & 0 deletions tableauserverclient/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
Metadata,
Metrics,
Projects,
RecycleBin,
Schedules,
ServerInfo,
ServerResponseError,
Expand Down Expand Up @@ -91,6 +92,7 @@
"Metadata",
"Metrics",
"Projects",
"RecycleBin",
"Schedules",
"ServerInfo",
"ServerResponseError",
Expand Down
2 changes: 2 additions & 0 deletions tableauserverclient/server/endpoint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +61,7 @@
"Metrics",
"OIDC",
"Projects",
"RecycleBin",
"Schedules",
"ServerInfo",
"ServerResponseError",
Expand Down
200 changes: 200 additions & 0 deletions tableauserverclient/server/endpoint/recycle_bin_endpoint.py
Original file line number Diff line number Diff line change
@@ -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]
1 change: 1 addition & 0 deletions tableauserverclient/server/request_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ class Field:
CreatedAt = "createdAt"
DatabaseName = "databaseName"
DatabaseUserName = "databaseUserName"
DeleterId = "deleterId"
Description = "description"
DisplayTabs = "displayTabs"
DomainName = "domainName"
Expand Down
2 changes: 2 additions & 0 deletions tableauserverclient/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Workbooks,
Datasources,
Projects,
RecycleBin,
Auth,
AuthFederation,
Schedules,
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions test/assets/recycle_bin_empty_job.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api https://help.tableau.com/samples/en-us/rest_api/ts-api_3_29.xsd">
<job id="33333333-3333-4333-8333-333333333333" mode="Asynchronous" type="EmptyRecycleBin" progress="0" createdAt="2026-03-04T05:06:07Z" finishCode="1"/>
</tsResponse>
Loading
Loading