Skip to content

Commit 4ec6de1

Browse files
committed
Confluence: fix cursor and add method of admin related level
1 parent f5d47e0 commit 4ec6de1

8 files changed

Lines changed: 283 additions & 14 deletions

‎atlassian/confluence/base.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import copy
44
import re
5-
from urllib.parse import urljoin, urlparse
5+
from urllib.parse import parse_qsl, urljoin, urlparse
66
import logging
77
from requests import HTTPError
88
from ..rest_client import AtlassianRestAPI
@@ -167,6 +167,7 @@ def _get_paged(
167167
params = {}
168168

169169
while True:
170+
current_url = url
170171
response = self.get(
171172
url,
172173
trailing=trailing,
@@ -190,6 +191,16 @@ def _get_paged(
190191
if url is None:
191192
break
192193

194+
# Cloud V2 cursor links contain a relative endpoint and a cursor
195+
# query. Reusing the already-resolved endpoint preserves both
196+
# ``/wiki`` and API-gateway tenant prefixes.
197+
parsed_next = urlparse(url)
198+
if getattr(self, "api_version", None) == 2 and parsed_next.query and not parsed_next.scheme:
199+
url = current_url
200+
params = dict(parse_qsl(parsed_next.query, keep_blank_values=True))
201+
trailing = False
202+
continue
203+
193204
if not urlparse(url).scheme:
194205
# Confluence returns both ``/rest/api/...`` and
195206
# ``rest/api/...`` forms for next links. Neither is an
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Confluence Cloud v2 administrative and workspace operations."""
2+
3+
4+
class AdminManagementOperations:
5+
"""Wrappers for v2 administrative resources not covered by legacy helpers."""
6+
7+
@staticmethod
8+
def _query(**values):
9+
return {key: value for key, value in values.items() if value is not None}
10+
11+
def _v2(self, path):
12+
return self.url_joiner("api/v2", path)
13+
14+
def get_admin_key(self):
15+
return self.get(self._v2("admin-key"))
16+
17+
def enable_admin_key(self):
18+
return self.post(self._v2("admin-key"))
19+
20+
def disable_admin_key(self):
21+
return self.delete(self._v2("admin-key"))
22+
23+
def get_space_permissions(self, cursor=None, limit=None):
24+
return self.get(self._v2("space-permissions"), params=self._query(cursor=cursor, limit=limit))
25+
26+
def get_space_permission_combinations(self, cursor=None, limit=None):
27+
return self.get(
28+
self._v2("space-permissions/transition/combinations"),
29+
params=self._query(cursor=cursor, limit=limit),
30+
)
31+
32+
def generate_space_permission_combinations(self, data=None):
33+
return self.post(self._v2("space-permissions/transition/combinations"), data=data)
34+
35+
def assign_space_permission_roles(self, data=None):
36+
return self.post(self._v2("space-permissions/transition/role-assignments"), data=data)
37+
38+
def remove_space_permission_access(self, data=None):
39+
return self.post(self._v2("space-permissions/transition/access-removals"), data=data)
40+
41+
def get_space_permission_transition_task(self, task_id):
42+
return self.get(self._v2(f"space-permissions/transition/tasks/{task_id}"))
43+
44+
def get_space_roles(
45+
self,
46+
space_id=None,
47+
role_type=None,
48+
principal_id=None,
49+
principal_type=None,
50+
cursor=None,
51+
limit=None,
52+
):
53+
return self.get(
54+
self._v2("space-roles"),
55+
params=self._query(
56+
**{
57+
"space-id": space_id,
58+
"role-type": role_type,
59+
"principal-id": principal_id,
60+
"principal-type": principal_type,
61+
"cursor": cursor,
62+
"limit": limit,
63+
}
64+
),
65+
)
66+
67+
def create_space_role(self, data=None):
68+
return self.post(self._v2("space-roles"), data=data)
69+
70+
def get_space_role(self, role_id):
71+
return self.get(self._v2(f"space-roles/{role_id}"))
72+
73+
def update_space_role(self, role_id, data=None):
74+
return self.put(self._v2(f"space-roles/{role_id}"), data=data)
75+
76+
def delete_space_role(self, role_id):
77+
return self.delete(self._v2(f"space-roles/{role_id}"))
78+
79+
def get_space_role_mode(self):
80+
return self.get(self._v2("space-role-mode"))
81+
82+
def bulk_get_users(self, data=None):
83+
return self.post(self._v2("users-bulk"), data=data)
84+
85+
def check_user_access_by_email(self, data=None):
86+
return self.post(self._v2("user/access/check-access-by-email"), data=data)
87+
88+
def invite_user_by_email(self, data=None):
89+
return self.post(self._v2("user/access/invite-by-email"), data=data)
90+
91+
def get_data_policy_metadata(self):
92+
return self.get(self._v2("data-policies/metadata"))
93+
94+
def get_data_policy_spaces(self, **filters):
95+
return self.get(self._v2("data-policies/spaces"), params=self._query(**filters))
96+
97+
def get_global_footer_comments(self, **params):
98+
return self.get(self._v2("footer-comments"), params=self._query(**params))
99+
100+
def create_global_footer_comment(self, data=None):
101+
return self.post(self._v2("footer-comments"), data=data)
102+
103+
def get_global_inline_comments(self, **params):
104+
return self.get(self._v2("inline-comments"), params=self._query(**params))
105+
106+
def create_global_inline_comment(self, data=None):
107+
return self.post(self._v2("inline-comments"), data=data)
108+
109+
def get_comment_children_v2(self, comment_id, comment_type="footer-comments", **params):
110+
return self.get(self._v2(f"{comment_type}/{comment_id}/children"), params=self._query(**params))
111+
112+
def get_comment_likes_count_v2(self, comment_id, comment_type="footer-comments"):
113+
return self.get(self._v2(f"{comment_type}/{comment_id}/likes/count"))
114+
115+
def get_comment_likes_users_v2(self, comment_id, comment_type="footer-comments", **params):
116+
return self.get(self._v2(f"{comment_type}/{comment_id}/likes/users"), params=self._query(**params))
117+
118+
def get_comment_operations_v2(self, comment_id, comment_type="footer-comments"):
119+
return self.get(self._v2(f"{comment_type}/{comment_id}/operations"))
120+
121+
def get_comment_versions_v2(self, comment_id, comment_type="footer-comments", **params):
122+
return self.get(self._v2(f"{comment_type}/{comment_id}/versions"), params=self._query(**params))
123+
124+
def get_comment_version_v2(self, comment_id, version_number, comment_type="footer-comments"):
125+
return self.get(self._v2(f"{comment_type}/{comment_id}/versions/{version_number}"))

‎atlassian/confluence/cloud/cloud.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .graphql import GraphQLOperations
1919
from .tasks import TaskOperations
2020
from .whiteboards import WhiteboardOperations
21+
from .admin_management import AdminManagementOperations
2122

2223
log = logging.getLogger(__name__)
2324

@@ -30,6 +31,7 @@ class ConfluenceCloud(
3031
TaskOperations,
3132
FolderOperations,
3233
DatabaseOperations,
34+
AdminManagementOperations,
3335
):
3436
"""
3537
Confluence Cloud API implementation class

‎atlassian/confluence_base.py‎

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import logging
66
from typing import Dict, List, Optional, Union
7-
from urllib.parse import urlparse
7+
from urllib.parse import parse_qsl, urlparse
88

99
from atlassian.rest_client import AtlassianRestAPI
1010

@@ -259,21 +259,32 @@ def _get_paged(
259259
if not next_url:
260260
break
261261

262-
# Use the next URL directly
263-
# Check if the response has a base URL provided (common in Confluence v2 API)
264-
base_url = response.get("_links", {}).get("base")
265-
if base_url and next_url.startswith("/"):
266-
# Construct the full URL using the base URL from the response
267-
url = f"{base_url}{next_url}"
262+
if isinstance(next_url, dict):
263+
next_url = next_url.get("href")
264+
if not next_url:
265+
break
266+
267+
# Cursor links are commonly relative and include the endpoint
268+
# path (for example ``/wiki/api/v2/spaces?cursor=...``). The
269+
# endpoint used for the first request is already resolved
270+
# against the tenant or API-gateway context, so rebuilding it
271+
# from ``_links.base`` can duplicate ``/wiki`` or drop an
272+
# ``/ex/confluence/{cloud_id}`` prefix. Keep the resolved
273+
# endpoint and advance only its query parameters.
274+
parsed_next = urlparse(next_url)
275+
if parsed_next.scheme:
276+
url = next_url
268277
absolute = True
278+
params = {}
279+
elif parsed_next.query:
280+
params = dict(parse_qsl(parsed_next.query, keep_blank_values=True))
269281
else:
282+
# A relative link without a query is unusual for cursor
283+
# pagination; retain the previous behavior as a safe
284+
# fallback for such responses.
270285
url = next_url
271-
# Check if the URL is absolute (has http:// or https://) or contains the server's domain
272-
if next_url.startswith(("http://", "https://")) or self.url.split("/")[2] in next_url:
273-
absolute = True
274-
else:
275-
absolute = False
276-
params = {}
286+
absolute = False
287+
params = {}
277288
trailing = False
278289

279290
return

‎docs/confluence.rst‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,24 @@ trash. A folder may have a page or another folder as its parent. Use
10821082
Scoped tokens require the corresponding ``write:folder:confluence``,
10831083
``read:folder:confluence``, or ``delete:folder:confluence`` scope.
10841084

1085+
Additional Cloud v2 resources
1086+
-----------------------------
1087+
1088+
The Cloud v2 client also exposes administrative and workspace operations from
1089+
the current OpenAPI contract:
1090+
1091+
.. code-block:: python
1092+
1093+
admin_key = confluence.get_admin_key()
1094+
permissions = confluence.get_space_permissions(limit=25)
1095+
roles = confluence.get_space_roles(space_id="<space-id>", limit=25)
1096+
users = confluence.bulk_get_users({"accountIds": ["<account-id>"]})
1097+
policies = confluence.get_data_policy_spaces(ids=["<space-id>"])
1098+
1099+
Global comments are available through ``get_global_footer_comments`` and
1100+
``get_global_inline_comments``. Their child, likes, operations, and version
1101+
resources are exposed by the corresponding ``*_v2`` helpers.
1102+
10851103
Cloud group members
10861104
-------------------
10871105

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from unittest.mock import patch
2+
3+
from atlassian.confluence.cloud.cloud import ConfluenceCloud
4+
5+
6+
def test_admin_key_and_space_role_operations_use_v2_routes():
7+
client = ConfluenceCloud("https://example.atlassian.net")
8+
with patch.object(client, "get", return_value={}) as get:
9+
client.get_admin_key()
10+
client.get_space_roles(space_id="S1", limit=10)
11+
assert get.call_args_list[0].args == ("api/v2/admin-key",)
12+
assert get.call_args_list[1].args == ("api/v2/space-roles",)
13+
assert get.call_args_list[1].kwargs["params"] == {"space-id": "S1", "limit": 10}
14+
15+
16+
def test_global_comment_and_data_policy_operations():
17+
client = ConfluenceCloud("https://example.atlassian.net")
18+
with patch.object(client, "post", return_value={}) as post:
19+
client.create_global_footer_comment({"body": {}})
20+
client.bulk_get_users({"accountIds": ["A1"]})
21+
assert post.call_args_list[0].args == ("api/v2/footer-comments",)
22+
assert post.call_args_list[1].args == ("api/v2/users-bulk",)
23+
24+
with patch.object(client, "get", return_value={}) as get:
25+
client.get_data_policy_spaces(ids=["S1"], limit=10)
26+
get.assert_called_once_with("api/v2/data-policies/spaces", params={"ids": ["S1"], "limit": 10})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from unittest.mock import patch
2+
3+
from atlassian import ConfluenceV2
4+
5+
6+
def test_cloud_v2_cursor_preserves_wiki_context():
7+
client = ConfluenceV2("https://example.atlassian.net")
8+
responses = [
9+
{
10+
"results": [{"id": "1"}],
11+
"_links": {"next": "/wiki/api/v2/spaces?limit=1&cursor=CURSOR"},
12+
},
13+
{"results": []},
14+
]
15+
with patch.object(client, "get", side_effect=responses) as get:
16+
assert list(client._get_paged("api/v2/spaces", params={"limit": 1})) == [{"id": "1"}]
17+
18+
assert get.call_args_list[1].args[0] == "api/v2/spaces"
19+
assert get.call_args_list[1].kwargs["params"] == {"limit": "1", "cursor": "CURSOR"}
20+
21+
22+
def test_cloud_v2_cursor_preserves_gateway_context():
23+
client = ConfluenceV2("https://api.atlassian.com/ex/confluence/CLOUD-ID")
24+
responses = [
25+
{
26+
"results": [{"id": "1"}],
27+
"_links": {"next": "/wiki/api/v2/spaces?limit=1&cursor=CURSOR"},
28+
},
29+
{"results": []},
30+
]
31+
with patch.object(client, "get", side_effect=responses) as get:
32+
list(client._get_paged("api/v2/spaces", params={"limit": 1}))
33+
34+
assert get.call_args_list[1].args[0] == "api/v2/spaces"
35+
assert get.call_args_list[1].kwargs["params"] == {"limit": "1", "cursor": "CURSOR"}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from unittest.mock import patch
2+
3+
from atlassian.confluence_base import ConfluenceBase
4+
5+
6+
def test_v2_relative_cursor_preserves_tenant_context_path():
7+
client = ConfluenceBase("https://example.atlassian.net", api_version=2)
8+
responses = [
9+
{
10+
"results": [{"id": "1"}],
11+
"_links": {
12+
"base": "https://example.atlassian.net/wiki",
13+
"next": "/wiki/api/v2/spaces?limit=1&cursor=CURSOR",
14+
},
15+
},
16+
{"results": []},
17+
]
18+
with patch.object(client, "get", side_effect=responses) as get:
19+
assert list(client._get_paged("api/v2/spaces", params={"limit": 1})) == [{"id": "1"}]
20+
21+
assert get.call_args_list[1].args[0] == "api/v2/spaces"
22+
assert get.call_args_list[1].kwargs["params"] == {"limit": "1", "cursor": "CURSOR"}
23+
24+
25+
def test_v2_relative_cursor_preserves_api_gateway_prefix():
26+
client = ConfluenceBase("https://api.atlassian.com/ex/confluence/CLOUD-ID", api_version=2)
27+
responses = [
28+
{
29+
"results": [{"id": "1"}],
30+
"_links": {
31+
"base": "https://example.atlassian.net/wiki",
32+
"next": "/wiki/api/v2/spaces?limit=1&cursor=CURSOR",
33+
},
34+
},
35+
{"results": []},
36+
]
37+
with patch.object(client, "get", side_effect=responses) as get:
38+
list(client._get_paged("api/v2/spaces", params={"limit": 1}))
39+
40+
assert get.call_args_list[1].args[0] == "api/v2/spaces"
41+
assert get.call_args_list[1].kwargs["params"] == {"limit": "1", "cursor": "CURSOR"}

0 commit comments

Comments
 (0)