Skip to content
Merged
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
50 changes: 50 additions & 0 deletions atlassian/confluence/cloud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}")
Expand Down
2 changes: 2 additions & 0 deletions docs/confluence.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 53 additions & 1 deletion tests/confluence/test_confluence_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -398,6 +400,56 @@ def test_update_page_skips_unchanged_content(self, mock_get, mock_put, confluenc
confluence_cloud.update_page("123", "Title", "<p>Same</p>", 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."""
Expand Down
Loading