Skip to content

Commit ec2a1e6

Browse files
authored
Merge branch 'atlassian-api:master' into master
2 parents a8956db + 63c4fcc commit ec2a1e6

File tree

9 files changed

+142
-21
lines changed

9 files changed

+142
-21
lines changed

atlassian/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.41.14
1+
3.41.16

atlassian/bitbucket/base.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,10 @@ def get_time(self, id):
164164
# The format contains a : in the timezone which is supported from 3.7 on.
165165
if sys.version_info <= (3, 7):
166166
value_str = RE_TIMEZONE.sub(r"\1\2", value_str)
167-
value = datetime.strptime(value_str, self.CONF_TIMEFORMAT)
167+
try:
168+
value = datetime.strptime(value_str, self.CONF_TIMEFORMAT)
169+
except ValueError:
170+
value = datetime.strptime(value_str, "%Y-%m-%dT%H:%M:%S.%fZ", tzinfo="UTC")
168171
else:
169172
value = value_str
170173

atlassian/confluence.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def __init__(self, url, *args, **kwargs):
3737
@staticmethod
3838
def _create_body(body, representation):
3939
if representation not in [
40+
"atlas_doc_format",
4041
"editor",
4142
"export_view",
4243
"view",
@@ -521,13 +522,14 @@ def get_draft_page_by_id(self, page_id, status="draft", expand=None):
521522
# operate differently between different collaborative modes
522523
return self.get_page_by_id(page_id=page_id, expand=expand, status=status)
523524

524-
def get_all_pages_by_label(self, label, start=0, limit=50):
525+
def get_all_pages_by_label(self, label, start=0, limit=50, expand=None):
525526
"""
526527
Get all page by label
527528
:param label:
528529
:param start: OPTIONAL: The start point of the collection to return. Default: None (0).
529530
:param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by
530531
fixed system limits. Default: 50
532+
:param expand: OPTIONAL: a comma separated list of properties to expand on the content
531533
:return:
532534
"""
533535
url = "rest/api/content/search"
@@ -538,6 +540,8 @@ def get_all_pages_by_label(self, label, start=0, limit=50):
538540
params["start"] = start
539541
if limit:
540542
params["limit"] = limit
543+
if expand:
544+
params["expand"] = expand
541545

542546
try:
543547
response = self.get(url, params=params)
@@ -792,6 +796,7 @@ def create_page(
792796
representation="storage",
793797
editor=None,
794798
full_width=False,
799+
status="current",
795800
):
796801
"""
797802
Create page from scratch
@@ -803,13 +808,15 @@ def create_page(
803808
:param representation: OPTIONAL: either Confluence 'storage' or 'wiki' markup format
804809
:param editor: OPTIONAL: v2 to be created in the new editor
805810
:param full_width: DEFAULT: False
811+
:param status: either 'current' or 'draft'
806812
:return:
807813
"""
808814
log.info('Creating %s "%s" -> "%s"', type, space, title)
809815
url = "rest/api/content/"
810816
data = {
811817
"type": type,
812818
"title": title,
819+
"status": status,
813820
"space": {"key": space},
814821
"body": self._create_body(body, representation),
815822
"metadata": {"properties": {}},
@@ -1210,7 +1217,8 @@ def attach_content(
12101217
:type name: ``str``
12111218
:param content: Contains the content which should be uploaded
12121219
:type content: ``binary``
1213-
:param content_type: Specify the HTTP content type. The default is
1220+
:param content_type: Specify the HTTP content type.
1221+
The default is "application/binary"
12141222
:type content_type: ``str``
12151223
:param comment: A comment describing this upload/file
12161224
:type comment: ``str``
@@ -1292,6 +1300,7 @@ def attach_file(
12921300
Is no name give the file name is used as name
12931301
:type name: ``str``
12941302
:param content_type: Specify the HTTP content type. The default is
1303+
The default is "application/binary"
12951304
:type content_type: ``str``
12961305
:param comment: A comment describing this upload/file
12971306
:type comment: ``str``
@@ -3067,10 +3076,8 @@ def add_user_to_group(self, username, group_name):
30673076
:param group_name: str - name of group to add user to
30683077
:return: Current state of the group
30693078
"""
3070-
url = "rest/api/2/group/user"
3071-
params = {"groupname": group_name}
3072-
data = {"name": username}
3073-
return self.post(url, params=params, data=data)
3079+
url = f"rest/api/user/{username}/group/{group_name}"
3080+
return self.put(url)
30743081

30753082
def add_space_permissions(
30763083
self,

atlassian/jira.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1232,7 +1232,7 @@ def get_issue_changelog(self, issue_key, start=None, limit=None):
12321232
return self.get(url, params=params)
12331233
else:
12341234
url = "{base_url}/{issue_key}?expand=changelog".format(base_url=base_url, issue_key=issue_key)
1235-
return (self.get(url) or {}).get("changelog", params)
1235+
return self._get_response_content(url, fields=[("changelog", params)])
12361236

12371237
def issue_add_json_worklog(self, key, worklog):
12381238
"""
@@ -1378,7 +1378,7 @@ def get_issue_labels(self, issue_key):
13781378
url = "{base_url}/{issue_key}?fields=labels".format(base_url=base_url, issue_key=issue_key)
13791379
if self.advanced_mode:
13801380
return self.get(url)
1381-
return (self.get(url) or {}).get("fields").get("labels")
1381+
return self._get_response_content(url, fields=[("fields",), ("labels",)])
13821382

13831383
def update_issue(self, issue_key, update):
13841384
"""
@@ -1776,6 +1776,7 @@ def create_or_update_issue_remote_links(
17761776
icon_url=None,
17771777
icon_title=None,
17781778
status_resolved=False,
1779+
application: dict = {},
17791780
):
17801781
"""
17811782
Add Remote Link to Issue, update url if global_id is passed
@@ -1787,6 +1788,7 @@ def create_or_update_issue_remote_links(
17871788
:param icon_url: str, OPTIONAL: Link to a 16x16 icon representing the type of the object in the remote system
17881789
:param icon_title: str, OPTIONAL: Text for the tooltip of the main icon describing the type of the object in the remote system
17891790
:param status_resolved: bool, OPTIONAL: if set to True, Jira renders the link strikethrough
1791+
:param application: dict, OPTIONAL: Application description
17901792
"""
17911793
base_url = self.resource_url("issue")
17921794
url = "{base_url}/{issue_key}/remotelink".format(base_url=base_url, issue_key=issue_key)
@@ -1802,6 +1804,8 @@ def create_or_update_issue_remote_links(
18021804
if icon_title:
18031805
icon_data["title"] = icon_title
18041806
data["object"]["icon"] = icon_data
1807+
if application:
1808+
data["application"] = application
18051809
return self.post(url, data=data)
18061810

18071811
def get_issue_remote_link_by_id(self, issue_key, link_id):
@@ -1919,12 +1923,14 @@ def set_issue_status_by_transition_id(self, issue_key, transition_id):
19191923
def get_issue_status(self, issue_key):
19201924
base_url = self.resource_url("issue")
19211925
url = "{base_url}/{issue_key}?fields=status".format(base_url=base_url, issue_key=issue_key)
1922-
return (((self.get(url) or {}).get("fields") or {}).get("status") or {}).get("name") or {}
1926+
fields = [("fields",), ("status",), ("name",)]
1927+
return self._get_response_content(url, fields=fields) or {}
19231928

19241929
def get_issue_status_id(self, issue_key):
19251930
base_url = self.resource_url("issue")
19261931
url = "{base_url}/{issue_key}?fields=status".format(base_url=base_url, issue_key=issue_key)
1927-
return (self.get(url) or {}).get("fields").get("status").get("id")
1932+
fields = [("fields",), ("status",), ("id",)]
1933+
return self._get_response_content(url, fields=fields)
19281934

19291935
def get_issue_transitions_full(self, issue_key, transition_id=None, expand=None):
19301936
"""
@@ -2721,7 +2727,7 @@ def get_project_actors_for_role_project(self, project_key, role_id):
27212727
"""
27222728
base_url = self.resource_url("project")
27232729
url = "{base_url}/{projectIdOrKey}/role/{id}".format(base_url=base_url, projectIdOrKey=project_key, id=role_id)
2724-
return (self.get(url) or {}).get("actors")
2730+
return self._get_response_content(url, fields=[("actors",)])
27252731

27262732
def delete_project_actors(self, project_key, role_id, actor, actor_type=None):
27272733
"""
@@ -3080,7 +3086,7 @@ def get_assignable_users_for_issue(self, issue_key, username=None, start=0, limi
30803086
def get_status_id_from_name(self, status_name):
30813087
base_url = self.resource_url("status")
30823088
url = "{base_url}/{name}".format(base_url=base_url, name=status_name)
3083-
return int((self.get(url) or {}).get("id"))
3089+
return int(self._get_response_content(url, fields=[("id",)]))
30843090

30853091
def get_status_for_project(self, project_key):
30863092
base_url = self.resource_url("project")
@@ -3181,7 +3187,7 @@ def get_issue_link_types(self):
31813187
a name and a label for the outward and inward link relationship.
31823188
"""
31833189
url = self.resource_url("issueLinkType")
3184-
return (self.get(url) or {}).get("issueLinkTypes")
3190+
return self._get_response_content(url, fields=[("issueLinkTypes",)])
31853191

31863192
def get_issue_link_types_names(self):
31873193
"""
@@ -3712,7 +3718,7 @@ def get_all_permissionschemes(self, expand=None):
37123718
params = {}
37133719
if expand:
37143720
params["expand"] = expand
3715-
return (self.get(url, params=params) or {}).get("permissionSchemes")
3721+
return self._get_response_content(url, params=params, fields=[("permissionSchemes",)])
37163722

37173723
def get_permissionscheme(self, permission_id, expand=None):
37183724
"""
@@ -3768,7 +3774,7 @@ def get_issue_security_schemes(self):
37683774
:return: list
37693775
"""
37703776
url = self.resource_url("issuesecurityschemes")
3771-
return self.get(url).get("issueSecuritySchemes")
3777+
return self._get_response_content(url, fields=[("issueSecuritySchemes",)])
37723778

37733779
def get_issue_security_scheme(self, scheme_id, only_levels=False):
37743780
"""
@@ -3785,7 +3791,7 @@ def get_issue_security_scheme(self, scheme_id, only_levels=False):
37853791
url = "{base_url}/{scheme_id}".format(base_url=base_url, scheme_id=scheme_id)
37863792

37873793
if only_levels is True:
3788-
return self.get(url).get("levels")
3794+
return self._get_response_content(url, fields=[("levels",)])
37893795
else:
37903796
return self.get(url)
37913797

atlassian/rest_client.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,34 @@ def get(
362362
log.error(e)
363363
return response.text
364364

365+
def _get_response_content(
366+
self,
367+
*args,
368+
fields,
369+
**kwargs,
370+
):
371+
"""
372+
:param fields: list of tuples in the form (field_name, default value (optional)).
373+
Used for chaining dictionary value accession.
374+
E.g. [("field1", "default1"), ("field2", "default2"), ("field3", )]
375+
"""
376+
response = self.get(*args, **kwargs)
377+
if "advanced_mode" in kwargs:
378+
advanced_mode = kwargs["advanced_mode"]
379+
else:
380+
advanced_mode = self.advanced_mode
381+
382+
if not advanced_mode: # dict
383+
for field in fields:
384+
response = response.get(*field)
385+
else: # requests.Response
386+
first_field = fields[0]
387+
response = response.json().get(*first_field)
388+
for field in fields[1:]:
389+
response = response.get(*field)
390+
391+
return response
392+
365393
def post(
366394
self,
367395
path,

atlassian/service_desk.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# coding=utf-8
22
import logging
33

4+
from requests import HTTPError
5+
46
from .rest_client import AtlassianRestAPI
57

68
log = logging.getLogger(__name__)
@@ -909,3 +911,22 @@ def create_request_type(
909911

910912
url = "rest/servicedeskapi/servicedesk/{}/requesttype".format(service_desk_id)
911913
return self.post(url, headers=self.experimental_headers, data=data)
914+
915+
def raise_for_status(self, response):
916+
"""
917+
Checks the response for an error status and raises an exception with the error message provided by the server
918+
:param response:
919+
:return:
920+
"""
921+
if response.status_code == 401 and response.headers.get("Content-Type") != "application/json;charset=UTF-8":
922+
raise HTTPError("Unauthorized (401)", response=response)
923+
924+
if 400 <= response.status_code < 600:
925+
try:
926+
j = response.json()
927+
error_msg = j["errorMessage"]
928+
except Exception as e:
929+
log.error(e)
930+
response.raise_for_status()
931+
else:
932+
raise HTTPError(error_msg, response=response)

docs/confluence.rst

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ Get page info
4343
confluence.get_draft_page_by_id(page_id, status='draft')
4444
4545
# Get all page by label
46-
confluence.get_all_pages_by_label(label, start=0, limit=50)
46+
confluence.get_all_pages_by_label(label, start=0, limit=50, expand=None)
4747
4848
# Get all pages from Space
4949
# content_type can be 'page' or 'blogpost'. Defaults to 'page'
@@ -114,14 +114,16 @@ Page actions
114114
115115
# Attach (upload) a file to a page, if it exists it will update the
116116
# automatically version the new file and keep the old one
117+
# content_type is default to "application/binary"
117118
confluence.attach_file(filename, name=None, content_type=None, page_id=None, title=None, space=None, comment=None)
118119
119120
# Attach (upload) a content to a page, if it exists it will update the
120121
# automatically version the new file and keep the old one
122+
# content_type is default to "application/binary"
121123
confluence.attach_content(content, name=None, content_type=None, page_id=None, title=None, space=None, comment=None)
122124
123-
# Download attachments from a page to local system. If download_path is None, current working directory will be used.
124-
confluence.download_attachments_from_page(page_id, download_path=None)
125+
# Download attachments from a page to local system. If path is None, current working directory will be used.
126+
confluence.download_attachments_from_page(page_id, path=None)
125127
126128
# Remove completely a file if version is None or delete version
127129
confluence.delete_attachment(page_id, filename, version=None)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
responses[
2+
'{"object": {"url": "https://confluence.atlassian-python.atlassian.net/display/Test", "title": "Unused link text", "status": {"resolved": false}}}'
3+
] = {
4+
"id": 10000,
5+
"self": "https://atlassian-python.atlassian.net/rest/api/2/issue/FOO-123/remotelink/10000",
6+
"application": {},
7+
}
8+
responses[
9+
'{"object": {"url": "https://confluence.atlassian-python.atlassian.net/display/Test", "title": "Unused link text", "status": {"resolved": false}}, "globalId": "appId=00000000-0000-0000-0000-000000000000&pageId=0", "application": {"type": "com.atlassian.confluence", "name": "Confluence"}}'
10+
] = {
11+
"id": 10000,
12+
"self": "https://atlassian-python.atlassian.net/rest/api/2/issue/FOO-123/remotelink/10000",
13+
"application": {
14+
"type": "com.atlassian.confluence",
15+
"name": "Confluence",
16+
},
17+
}

tests/test_jira.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,40 @@ def test_delete_issue_property_not_found(self):
9292
self.jira.get_issue_property("FOO-123", "NotFoundBar1")
9393
with self.assertRaises(HTTPError):
9494
self.jira.get_issue_property("FOONotFound-123", "NotFoundBar1")
95+
96+
def test_post_issue_remotelink(self):
97+
"""Create a new remote link"""
98+
resp = self.jira.create_or_update_issue_remote_links(
99+
"FOO-123",
100+
"https://confluence.atlassian-python.atlassian.net/display/Test",
101+
"Unused link text",
102+
)
103+
self.assertEqual(resp["id"], 10000)
104+
self.assertEqual(
105+
resp["self"], "https://atlassian-python.atlassian.net/rest/api/2/issue/FOO-123/remotelink/10000"
106+
)
107+
self.assertDictEqual(resp["application"], {})
108+
109+
def test_post_issue_remotelink_confluence(self):
110+
"""Create a new Confluence remote link"""
111+
resp = self.jira.create_or_update_issue_remote_links(
112+
"FOO-123",
113+
"https://confluence.atlassian-python.atlassian.net/display/Test",
114+
"Unused link text",
115+
global_id="appId=00000000-0000-0000-0000-000000000000&pageId=0",
116+
application={
117+
"type": "com.atlassian.confluence",
118+
"name": "Confluence",
119+
},
120+
)
121+
self.assertEqual(resp["id"], 10000)
122+
self.assertEqual(
123+
resp["self"], "https://atlassian-python.atlassian.net/rest/api/2/issue/FOO-123/remotelink/10000"
124+
)
125+
self.assertDictEqual(
126+
resp["application"],
127+
{
128+
"type": "com.atlassian.confluence",
129+
"name": "Confluence",
130+
},
131+
)

0 commit comments

Comments
 (0)