From 64eeb3fdb8e1f70830d25b190822db2e10001fa2 Mon Sep 17 00:00:00 2001 From: LuisFigueroaG Date: Sun, 25 Oct 2026 18:02:36 -0300 Subject: [PATCH] [Confluence] ConfluenceCloud: add remove_page docs/confluence.rst documents remove_page(page_id, status=None, recursive=False), and the Server client has it, but the Cloud client does not, so Confluence(url="https://.atlassian.net") and ConfluenceCloud raise "'Cloud' object has no attribute 'remove_page'". Add remove_page to Cloud with the same arguments, return value and error mapping as the Server client. It sends DELETE to /wiki/rest/api/content/{id}, the endpoint delete_content already uses, and with recursive=True lists all children before removing them. --- atlassian/confluence/cloud/__init__.py | 50 +++++++++++++++++++++ docs/confluence.rst | 2 + tests/confluence/test_confluence_cloud.py | 54 ++++++++++++++++++++++- 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/atlassian/confluence/cloud/__init__.py b/atlassian/confluence/cloud/__init__.py index 51bce6e86..d4729ac70 100644 --- a/atlassian/confluence/cloud/__init__.py +++ b/atlassian/confluence/cloud/__init__.py @@ -9,8 +9,10 @@ from requests import HTTPError from atlassian import utils from atlassian.errors import ( + ApiConflictError, ApiError, ApiNotFoundError, + ApiPermissionError, ) from .cloud import ConfluenceCloud # noqa: F401 @@ -436,6 +438,54 @@ def update_page( data["version"]["message"] = version_comment return self.put(f"rest/api/content/{page_id}", data=data, params={"status": "current"}) + def remove_page(self, page_id, status=None, recursive=False): + """ + Remove a page and optionally its child pages. + + :param page_id: page ID + :param status: optional content status (for example ``"trashed"``) + :param recursive: if True, remove child pages before this page + :return: the successful HTTP status code (normally ``204``). When the + client has ``advanced_mode=True``, return the raw response instead. + """ + url = f"rest/api/content/{page_id}" + if recursive: + # List every child before deleting any of them, so offset + # pagination cannot skip pages that shift after a delete. + children_pages = list(self.get_page_child_by_type(page_id)) + for children_page in children_pages: + self.remove_page(children_page.get("id"), status, recursive) + params = {} + if status: + params["status"] = status + + try: + response = self.delete(url, params=params, advanced_mode=True) + except HTTPError as e: + if e.response.status_code == 403: + raise ApiPermissionError( + "The calling user does not have permission to trash or purge the content", + reason=e, + ) + if e.response.status_code == 404: + # Raise ApiError as the documented reason is ambiguous + raise ApiError( + "There is no content with the given id, or the calling " + "user does not have permission to trash or purge the content", + reason=e, + ) + if e.response.status_code == 409: + raise ApiConflictError( + "There is a stale data object conflict when trying to delete a draft", + reason=e, + ) + + raise + + if self.advanced_mode: + return response + return response.status_code + def get_page_child_count(self, page_id, type="page"): """Return the number of direct children of ``type`` without listing them.""" page = self.get_page_by_id(page_id, expand=f"children.{type}") diff --git a/docs/confluence.rst b/docs/confluence.rst index 5271dcd5a..f9f9884d2 100644 --- a/docs/confluence.rst +++ b/docs/confluence.rst @@ -277,6 +277,8 @@ Page actions # With recursive=True, all descendants are listed before deletion so # server-side pagination cannot leave later child pages behind. + # ``ConfluenceCloud`` (and ``Confluence`` with a Cloud URL) take the same + # arguments and delete through ``/wiki/rest/api/content``. confluence.remove_page(page_id, status=None, recursive=False) # Remove any content diff --git a/tests/confluence/test_confluence_cloud.py b/tests/confluence/test_confluence_cloud.py index 4e4877eb5..b929c8566 100644 --- a/tests/confluence/test_confluence_cloud.py +++ b/tests/confluence/test_confluence_cloud.py @@ -7,10 +7,12 @@ import logging from unittest.mock import patch +from requests import HTTPError, Response + from atlassian.confluence import Confluence, ConfluenceCloud from atlassian.confluence.cloud import Cloud from atlassian.confluence.cloud.cloud import ConfluenceCloud as ConfluenceCloudV2 -from atlassian.errors import ApiError +from atlassian.errors import ApiError, ApiPermissionError @pytest.fixture @@ -398,6 +400,56 @@ def test_update_page_skips_unchanged_content(self, mock_get, mock_put, confluenc confluence_cloud.update_page("123", "Title", "

Same

", always_update=True) assert mock_put.call_args.kwargs["data"]["version"]["number"] == 5 + @patch.object(ConfluenceCloud, "delete") + def test_remove_page(self, mock_delete, confluence_cloud): + response = Response() + response.status_code = 204 + mock_delete.return_value = response + + assert confluence_cloud.remove_page("123") == 204 + mock_delete.assert_called_once_with("rest/api/content/123", params={}, advanced_mode=True) + + @patch.object(ConfluenceCloud, "delete") + def test_remove_page_passes_status(self, mock_delete, confluence_cloud): + mock_delete.return_value = Response() + + confluence_cloud.remove_page("123", status="trashed") + + mock_delete.assert_called_once_with("rest/api/content/123", params={"status": "trashed"}, advanced_mode=True) + + @patch.object(ConfluenceCloud, "delete") + @patch.object(ConfluenceCloud, "get_page_child_by_type") + def test_remove_page_recursive_removes_children_first(self, mock_get_children, mock_delete, confluence_cloud): + mock_get_children.side_effect = [iter([{"id": "first"}, {"id": "second"}]), iter(()), iter(())] + mock_delete.return_value = Response() + + confluence_cloud.remove_page("root", recursive=True) + + assert [call.args[0] for call in mock_delete.call_args_list] == [ + "rest/api/content/first", + "rest/api/content/second", + "rest/api/content/root", + ] + + @patch.object(ConfluenceCloud, "delete") + def test_remove_page_403_raises_permission_error(self, mock_delete, confluence_cloud): + response = Response() + response.status_code = 403 + mock_delete.side_effect = HTTPError(response=response) + + with pytest.raises(ApiPermissionError, match="does not have permission to trash or purge"): + confluence_cloud.remove_page("123") + + @patch.object(ConfluenceCloud, "delete") + def test_remove_page_through_confluence_with_cloud_url(self, mock_delete): + response = Response() + response.status_code = 204 + mock_delete.return_value = response + confluence = Confluence(url="https://test.atlassian.net", username="user", password="token") + + assert confluence.remove_page("123") == 204 + mock_delete.assert_called_once_with("rest/api/content/123", params={}, advanced_mode=True) + @patch.object(ConfluenceCloud, "delete") def test_delete_content(self, mock_delete, confluence_cloud): """Test delete_content method."""