diff --git a/atlassian/__init__.py b/atlassian/__init__.py index 0b58404ce..13215a75a 100644 --- a/atlassian/__init__.py +++ b/atlassian/__init__.py @@ -16,6 +16,7 @@ from .portfolio import Portfolio from .service_desk import ServiceDesk from .service_desk import ServiceDesk as ServiceManagement +from .tempo import TempoCloud, TempoServer from .xray import Xray __all__ = [ @@ -35,4 +36,6 @@ "Insight", "Assets", "AssetsCloud", + "TempoCloud", + "TempoServer", ] diff --git a/atlassian/confluence/__init___.py b/atlassian/confluence/__init___.py new file mode 100644 index 000000000..d72da9e02 --- /dev/null +++ b/atlassian/confluence/__init___.py @@ -0,0 +1,3925 @@ +# coding=utf-8 +import io +import json +import logging +import os +import re +import time +from typing import cast + +import requests +from bs4 import BeautifulSoup +from deprecated import deprecated +from requests import HTTPError + +from atlassian import utils +from atlassian.errors import ( + ApiConflictError, + ApiError, + ApiNotAcceptable, + ApiNotFoundError, + ApiPermissionError, + ApiValueError, +) +from atlassian.rest_client import AtlassianRestAPI + +log = logging.getLogger(__name__) + + +class Confluence(AtlassianRestAPI): + content_types = { + ".gif": "image/gif", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".pdf": "application/pdf", + ".doc": "application/msword", + ".xls": "application/vnd.ms-excel", + ".svg": "image/svg+xml", + } + + def __init__(self, url, *args, **kwargs): + if ("atlassian.net" in url or "jira.com" in url) and ("/wiki" not in url): + url = AtlassianRestAPI.url_joiner(url, "/wiki") + if "cloud" not in kwargs: + kwargs["cloud"] = True + super(Confluence, self).__init__(url, *args, **kwargs) + + @staticmethod + def _create_body(body, representation): + if representation not in [ + "atlas_doc_format", + "editor", + "export_view", + "view", + "storage", + "wiki", + ]: + raise ValueError("Wrong value for representation, it should be either wiki or storage") + + return {representation: {"value": body, "representation": representation}} + + def _get_paged( + self, + url, + params=None, + data=None, + flags=None, + trailing=None, + absolute=False, + ): + """ + Used to get the paged data + + :param url: string: The url to retrieve + :param params: dict (default is None): The parameter's + :param data: dict (default is None): The data + :param flags: string[] (default is None): The flags + :param trailing: bool (default is None): If True, a trailing slash is added to the url + :param absolute: bool (default is False): If True, the url is used absolute and not relative to the root + + :return: A generator object for the data elements + """ + + if params is None: + params = {} + + while True: + response = self.get( + url, + trailing=trailing, + params=params, + data=data, + flags=flags, + absolute=absolute, + ) + if "results" not in response: + return + + for value in response.get("results", []): + yield value + + # According to Cloud and Server documentation the links are returned the same way: + # https://developer.atlassian.com/cloud/confluence/rest/api-group-content/#api-wiki-rest-api-content-get + # https://developer.atlassian.com/server/confluence/pagination-in-the-rest-api/ + url = response.get("_links", {}).get("next") + if url is None: + break + # From now on we have relative URLs with parameters + absolute = False + # Params are now provided by the url + params = {} + # Trailing should not be added as it is already part of the url + trailing = False + + return + + def page_exists(self, space, title, type=None): + """ + Check if title exists as page. + :param space: Space key + :param title: Title of the page + :param type: type of the page, 'page' or 'blogpost'. Defaults to 'page' + :return: + """ + url = "rest/api/content" + params = {} + if space is not None: + params["spaceKey"] = str(space) + if title is not None: + params["title"] = str(title) + if type is not None: + params["type"] = str(type) + + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 404: + raise ApiPermissionError( + "The calling user does not have permission to view the content", + reason=e, + ) + + raise + + if response.get("results"): + return True + else: + return False + + def share_with_others(self, page_id, group, message): + """ + Notify members (currently only groups implemented) about something on that page + """ + url = "rest/share-page/latest/share" + params = { + "contextualPageId": page_id, + # "emails": [], + "entityId": page_id, + "entityType": "page", + "groups": group, + "note": message, + # "users":[] + } + r = self.post(url, json=params, headers={"contentType": "application/json; charset=utf-8"}, advanced_mode=True) + if r.status_code != 200: + raise Exception(f"failed sharing content {r.status_code}: {r.text}") + + def get_page_child_by_type(self, page_id, type="page", start=None, limit=None, expand=None): + """ + Provide content by type (page, blog, comment) + :param page_id: A string containing the id of the type content container. + :param type: + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: how many items should be returned after the start index. Default: Site limit 200. + :param expand: OPTIONAL: expand e.g. history + :return: + """ + params = {} + if start is not None: + params["start"] = int(start) + if limit is not None: + params["limit"] = int(limit) + if expand is not None: + params["expand"] = expand + + url = f"rest/api/content/{page_id}/child/{type}" + log.info(url) + + try: + if not self.advanced_mode and start is None and limit is None: + return self._get_paged(url, params=params) + else: + response = self.get(url, params=params) + if self.advanced_mode: + return response + return response.get("results") + except HTTPError as 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 view the content", + reason=e, + ) + + raise + + def get_child_title_list(self, page_id, type="page", start=None, limit=None): + """ + Find a list of Child title + :param page_id: A string containing the id of the type content container. + :param type: + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: how many items should be returned after the start index. Default: Site limit 200. + :return: + """ + child_page = self.get_page_child_by_type(page_id, type, start, limit) + child_title_list = [child["title"] for child in child_page] + return child_title_list + + def get_child_id_list(self, page_id, type="page", start=None, limit=None): + """ + Find a list of Child id + :param page_id: A string containing the id of the type content container. + :param type: + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: how many items should be returned after the start index. Default: Site limit 200. + :return: + """ + child_page = self.get_page_child_by_type(page_id, type, start, limit) + child_id_list = [child["id"] for child in child_page] + return child_id_list + + def get_child_pages(self, page_id): + """ + Get child pages for the provided page_id + :param page_id: + :return: + """ + return self.get_page_child_by_type(page_id=page_id, type="page") + + def get_page_id(self, space, title, type="page"): + """ + Provide content id from search result by title and space. + :param space: SPACE key + :param title: title + :param type: type of content: Page or Blogpost. Defaults to page + :return: + """ + return (self.get_page_by_title(space, title, type=type) or {}).get("id") + + def get_parent_content_id(self, page_id): + """ + Provide parent content id from page id + :type page_id: str + :return: + """ + parent_content_id = None + try: + parent_content_id = (self.get_page_by_id(page_id=page_id, expand="ancestors").get("ancestors") or {})[ + -1 + ].get("id") or None + except Exception as e: + log.error(e) + return parent_content_id + + def get_parent_content_title(self, page_id): + """ + Provide parent content title from page id + :type page_id: str + :return: + """ + parent_content_title = None + try: + parent_content_title = (self.get_page_by_id(page_id=page_id, expand="ancestors").get("ancestors") or {})[ + -1 + ].get("title") or None + except Exception as e: + log.error(e) + return parent_content_title + + def get_page_space(self, page_id): + """ + Provide space key from content id. + :param page_id: content ID + :return: + """ + return ((self.get_page_by_id(page_id, expand="space") or {}).get("space") or {}).get("key") or None + + def get_pages_by_title(self, space, title, start=0, limit=200, expand=None): + """ + Provide pages by title search + :param space: Space key + :param title: Title of the page + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of labels to return, this may be restricted by + fixed system limits. Default: 200. + :param expand: OPTIONAL: expand e.g. history + :return: The JSON data returned from searched results the content endpoint, or the results of the + callback. Will raise requests.HTTPError on bad input, potentially. + If it has IndexError then return the None. + """ + return self.get_page_by_title(space, title, start, limit, expand) + + def get_page_by_title(self, space, title, start=0, limit=1, expand=None, type="page"): + """ + Returns the first page on a piece of Content. + :param space: Space key + :param title: Title of the page + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of labels to return, this may be restricted by + fixed system limits. Default: 1. + :param expand: OPTIONAL: expand e.g. history + :param type: OPTIONAL: Type of content: Page or Blogpost. Defaults to page + :return: The JSON data returned from searched results the content endpoint, or the results of the + callback. Will raise requests.HTTPError on bad input, potentially. + If it has IndexError then return the None. + """ + url = "rest/api/content" + params = {"type": type} + if start is not None: + params["start"] = int(start) + if limit is not None: + params["limit"] = int(limit) + if expand is not None: + params["expand"] = expand + if space is not None: + params["spaceKey"] = str(space) + if title is not None: + params["title"] = str(title) + + if self.advanced_mode: + return self.get(url, params=params) + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 404: + raise ApiPermissionError( + "The calling user does not have permission to view the content", + reason=e, + ) + + raise + try: + return response.get("results")[0] + except (IndexError, TypeError) as e: + log.error(f"Can't find '{title}' page on {self.url}") + log.debug(e) + return None + + def get_page_by_id(self, page_id, expand=None, status=None, version=None): + """ + Returns a piece of Content. + Example request URI(s): + http://example.com/confluence/rest/api/content/1234?expand=space,body.view,version,container + http://example.com/confluence/rest/api/content/1234?status=any + :param page_id: Content ID + :param status: (str) list of Content statuses to filter results on. Default value: [current] + :param version: (int) + :param expand: OPTIONAL: Default value: history,space,version + We can also specify some extensions such as extensions.inlineProperties + (for getting inline comment-specific properties) or extensions. Resolution + for the resolution status of each comment in the results + :return: + """ + params = {} + if expand: + params["expand"] = expand + if status: + params["status"] = status + if version: + params["version"] = version + url = f"rest/api/content/{page_id}" + + try: + response = self.get(url, params=params) + except HTTPError as 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 view the content", + reason=e, + ) + + raise + + return response + + def get_tables_from_page(self, page_id): + """ + Fetches html tables added to confluence page + :param page_id: integer confluence page_id + :return: json object with page_id, number_of_tables_in_page + and list of list tables_content representing scraped tables + """ + try: + page_content = self.get_page_by_id(page_id, expand="body.storage")["body"]["storage"]["value"] + + if page_content: + tables_raw = [ + [[cell.text for cell in row("th") + row("td")] for row in table("tr")] + for table in BeautifulSoup(page_content, features="lxml")("table") + ] + if len(tables_raw) > 0: + return json.dumps( + { + "page_id": page_id, + "number_of_tables_in_page": len(tables_raw), + "tables_content": tables_raw, + } + ) + else: + return { + "No tables found for page: ": page_id, + } + else: + return {"Page content is empty"} + except HTTPError as e: + if e.response.status_code == 404: + # Raise ApiError as the documented reason is ambiguous + log.error("Couldn't retrieve tables from page", page_id) + raise ApiError( + "There is no content with the given pageid, pageid params is not an integer " + "or the calling user does not have permission to view the page", + reason=e, + ) + except Exception as e: + log.error("Error occured", e) + + def scrap_regex_from_page(self, page_id, regex): + """ + Method scraps regex patterns from a Confluence page_id. + + :param page_id: The ID of the Confluence page. + :param regex: The regex pattern to scrape. + :return: A list of regex matches. + """ + regex_output = [] + page_output = self.get_page_by_id(page_id, expand="body.storage")["body"]["storage"]["value"] + try: + if page_output is not None: + description_matches = [x.group(0) for x in re.finditer(regex, page_output)] + if description_matches: + regex_output.extend(description_matches) + return regex_output + except HTTPError as e: + if e.response.status_code == 404: + # Raise ApiError as the documented reason is ambiguous + log.error("couldn't find page_id : ", page_id) + raise ApiNotFoundError( + "There is no content with the given page id," + "or the calling user does not have permission to view the page", + reason=e, + ) + + def get_page_labels(self, page_id, prefix=None, start=None, limit=None): + """ + Returns the list of labels on a piece of Content. + :param page_id: A string containing the id of the labels content container. + :param prefix: OPTIONAL: The prefixes to filter the labels with {@see Label.Prefix}. + Default: None. + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of labels to return, this may be restricted by + fixed system limits. Default: 200. + :return: The JSON data returned from the content/{id}/label endpoint, or the results of the + callback. Will raise requests.HTTPError on bad input, potentially. + """ + url = f"rest/api/content/{page_id}/label" + params = {} + if prefix: + params["prefix"] = prefix + if start is not None: + params["start"] = int(start) + if limit is not None: + params["limit"] = int(limit) + + try: + response = self.get(url, params=params) + except HTTPError as 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 view the content", + reason=e, + ) + + raise + + return response + + def get_page_comments( + self, + content_id, + expand=None, + parent_version=None, + start=0, + limit=25, + location=None, + depth=None, + ): + """ + + :param content_id: + :param expand: extensions.inlineProperties,extensions.resolution + :param parent_version: + :param start: + :param limit: + :param location: inline or not + :param depth: + :return: + """ + params = {"id": content_id, "start": start, "limit": limit} + if expand: + params["expand"] = expand + if parent_version: + params["parentVersion"] = parent_version + if location: + params["location"] = location + if depth: + params["depth"] = depth + url = f"rest/api/content/{content_id}/child/comment" + + try: + response = self.get(url, params=params) + except HTTPError as 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 view the content", + reason=e, + ) + + raise + + return response + + def get_draft_page_by_id(self, page_id, status="draft", expand=None): + """ + Gets content by id with status = draft + :param page_id: Content ID + :param status: (str) list of content statuses to filter results on. Default value: [draft] + :param expand: OPTIONAL: Default value: history,space,version + We can also specify some extensions such as extensions.inlineProperties + (for getting inline comment-specific properties) or extensions. Resolution + for the resolution status of each comment in the results + :return: + """ + # Version not passed since draft versions don't match the page and + # operate differently between different collaborative modes + return self.get_page_by_id(page_id=page_id, expand=expand, status=status) + + def get_all_pages_by_label(self, label, start=0, limit=50, expand=None): + """ + Get all page by label + :param label: + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by + fixed system limits. Default: 50 + :param expand: OPTIONAL: a comma separated list of properties to expand on the content + :return: + """ + url = "rest/api/content/search" + params = {} + if label: + params["cql"] = f'type={"page"} AND label="{label}"' + if start: + params["start"] = start + if limit: + params["limit"] = limit + if expand: + params["expand"] = expand + + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 400: + raise ApiValueError("The CQL is invalid or missing", reason=e) + + raise + + return response.get("results") + + def get_all_pages_from_space_raw( + self, + space, + start=0, + limit=50, + status=None, + expand=None, + content_type="page", + ): + """ + Get all pages from space + + :param space: + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by + fixed system limits. Default: 50 + :param status: OPTIONAL: list of statuses the content to be found is in. + Defaults to current is not specified. + If set to 'any', content in 'current' and 'trashed' status will be fetched. + Does not support 'historical' status for now. + :param expand: OPTIONAL: a comma separated list of properties to expand on the content. + Default value: history,space,version. + :param content_type: the content type to return. Default value: page. Valid values: page, blogpost. + :return: + """ + url = "rest/api/content" + params = {} + if space: + params["spaceKey"] = space + if start: + params["start"] = start + if limit: + params["limit"] = limit + if status: + params["status"] = status + if expand: + params["expand"] = expand + if content_type: + params["type"] = content_type + + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 404: + raise ApiPermissionError( + "The calling user does not have permission to view the content", + reason=e, + ) + + raise + + return response + + def get_all_pages_from_space( + self, + space, + start=0, + limit=50, + status=None, + expand=None, + content_type="page", + ): + """ + Retrieve all pages from a Confluence space. + + :param space: The space key to fetch pages from. + :param start: OPTIONAL: The starting point of the collection. Default: 0. + :param limit: OPTIONAL: The maximum number of pages per request. Default: 50. + :param status: OPTIONAL: Filter pages by status ('current', 'trashed', 'any'). Default: None. + :param expand: OPTIONAL: Comma-separated list of properties to expand. Default: history,space,version. + :param content_type: OPTIONAL: The content type to return ('page', 'blogpost'). Default: page. + :return: List containing all pages from the specified space. + """ + all_pages = [] # Initialize an empty list to store all pages + while True: + # Fetch a single batch of pages + response = self.get_all_pages_from_space_raw( + space=space, + start=start, + limit=limit, + status=status, + expand=expand, + content_type=content_type, + ) + + # Extract results from the response + results = response.get("results", []) + all_pages.extend(results) # Add the current batch of pages to the list + + # Break the loop if no more pages are available + if len(results) < limit: + break + + # Increment the start index for the next batch + start += limit + return all_pages + + def get_all_pages_from_space_as_generator( + self, + space, + start=0, + limit=50, + status=None, + expand="history,space,version", + content_type="page", + ): + """ + Retrieve all pages from a Confluence space using pagination. + + :param space: The space key to fetch pages from. + :param start: OPTIONAL: The starting point of the collection. Default: 0. + :param limit: OPTIONAL: The maximum number of pages per request. Default: 50. + :param status: OPTIONAL: Filter pages by status ('current', 'trashed', 'any'). Default: None. + :param expand: OPTIONAL: Comma-separated list of properties to expand. Default: history,space,version. + :param content_type: OPTIONAL: The content type to return ('page', 'blogpost'). Default: page. + :return: Generator yielding pages one by one. + """ + while True: + # Fetch a single batch of pages + response = self.get_all_pages_from_space_raw( + space=space, + start=start, + limit=limit, + status=status, + expand=expand, + content_type=content_type, + ) + + # Extract results from the response + results = response.get("results", []) + yield from results # Yield each page individually + + # Break the loop if no more pages are available + if len(results) < limit: + break + start += limit + pass + + def get_all_pages_from_space_trash(self, space, start=0, limit=500, status="trashed", content_type="page"): + """ + Get list of pages from trash + :param space: + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by + fixed system limits. Default: 500 + :param status: + :param content_type: the content type to return. Default value: page. Valid values: page, blogpost. + :return: + """ + return self.get_all_pages_from_space(space, start, limit, status, content_type=content_type) + + def get_all_draft_pages_from_space(self, space, start=0, limit=500, status="draft"): + """ + Get list of draft pages from space + Use case is cleanup old drafts from Confluence + :param space: + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by + fixed system limits. Default: 500 + :param status: + :return: + """ + return self.get_all_pages_from_space(space, start, limit, status) + + def get_all_draft_pages_from_space_through_cql(self, space, start=0, limit=500, status="draft"): + """ + Search list of draft pages by space key + Use case is cleanup old drafts from Confluence + :param space: Space Key + :param status: Can be changed + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by + fixed system limits. Default: 500 + :return: + """ + url = f"rest/api/content?cql=space=spaceKey={space} and status={status}" + params = {} + if limit: + params["limit"] = limit + if start: + params["start"] = start + + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 404: + raise ApiPermissionError( + "The calling user does not have permission to view the content", + reason=e, + ) + + raise + + return response.get("results") + + def get_all_pages_by_space_ids_confluence_cloud( + self, + space_ids, + batch_size=250, + sort=None, + status=None, + title=None, + body_format=None, + ): + """ + Get all pages from a set of space ids: + https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-page/#api-pages-get + :param space_ids: A Set of space IDs passed as a filter to Confluence + :param batch_size: OPTIONAL: The batch size of pages to retrieve from confluence per request MAX is 250. + Default: 250 + :param sort: OPTIONAL: The order the pages are retrieved in. + Valid values: + id, -id, created-date, -created-date, modified-date, -modified-date, title, -title + :param status: OPTIONAL: Filter pages based on their status. + Valid values: current, archived, deleted, trashed + Default: current,archived + :param title: OPTIONAL: Filter pages based on their title. + :param body_format: OPTIONAL: The format of the body in the response. Valid values: storage, atlas_doc_format + :return: + """ + path = "/api/v2/pages" + params = {} + if space_ids: + params["space-id"] = ",".join(space_ids) + if batch_size: + params["limit"] = batch_size + if sort: + params["sort"] = sort + if status: + params["status"] = status + if title: + params["title"] = title + if body_format: + params["body-format"] = body_format + + _all_pages = [] + try: + while True: + response = self.get(path, params=params) + + pages = response.get("results") + _all_pages = _all_pages + pages + + links = response.get("_links") + if links is not None and "next" in links: + path = response["_links"]["next"].removeprefix("/wiki/") + params = {} + else: + break + except HTTPError as e: + if e.response.status_code == 400: + raise ApiValueError( + "The configured params cannot be interpreted by Confluence" + "Check the api documentation for valid values for status, expand, and sort params", + reason=e, + ) + if e.response.status_code == 401: + raise HTTPError("Unauthorized (401)", response=response) + raise + + return _all_pages + + @deprecated(version="2.4.2", reason="Use get_all_restrictions_for_content()") + def get_all_restictions_for_content(self, content_id): + """Let's use the get_all_restrictions_for_content()""" + return self.get_all_restrictions_for_content(content_id=content_id) + + def get_all_restrictions_for_content(self, content_id): + """ + Returns info about all restrictions by operation. + :param content_id: + :return: Return the raw json response + """ + url = f"rest/api/content/{content_id}/restriction/byOperation" + return self.get(url) + + def remove_page_from_trash(self, page_id): + """ + This method removes a page from trash + :param page_id: + :return: + """ + return self.remove_page(page_id=page_id, status="trashed") + + def remove_page_as_draft(self, page_id): + """ + This method removes a page from trash if it is a draft + :param page_id: + :return: + """ + return self.remove_page(page_id=page_id, status="draft") + + def remove_content(self, content_id): + """ + Remove any content + :param content_id: + :return: + """ + try: + response = self.delete(f"rest/api/content/{content_id}") + except HTTPError as 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 + + return response + + def remove_page(self, page_id, status=None, recursive=False): + """ + This method removes a page, if it has recursive flag, method removes including child pages + :param page_id: + :param status: OPTIONAL: type of page + :param recursive: OPTIONAL: if True - will recursively delete all children pages too + :return: + """ + url = f"rest/api/content/{page_id}" + if recursive: + children_pages = 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) + except HTTPError as 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 + + return response + + def create_page( + self, + space, + title, + body, + parent_id=None, + type="page", + representation="storage", + editor=None, + full_width=False, + status="current", + ): + """ + Create page from scratch + :param space: + :param title: + :param body: + :param parent_id: + :param type: + :param representation: OPTIONAL: either Confluence 'storage' or 'wiki' markup format + :param editor: OPTIONAL: v2 to be created in the new editor + :param full_width: DEFAULT: False + :param status: either 'current' or 'draft' + :return: + """ + log.info('Creating %s "%s" -> "%s"', type, space, title) + url = "rest/api/content/" + data = { + "type": type, + "title": title, + "status": status, + "space": {"key": space}, + "body": self._create_body(body, representation), + "metadata": {"properties": {}}, + } + if parent_id: + data["ancestors"] = [{"type": type, "id": parent_id}] + if editor is not None and editor in ["v1", "v2"]: + data["metadata"]["properties"]["editor"] = {"value": editor} + if full_width is True: + data["metadata"]["properties"]["content-appearance-draft"] = {"value": "full-width"} + data["metadata"]["properties"]["content-appearance-published"] = {"value": "full-width"} + else: + data["metadata"]["properties"]["content-appearance-draft"] = {"value": "fixed-width"} + data["metadata"]["properties"]["content-appearance-published"] = {"value": "fixed-width"} + + try: + response = self.post(url, data=data) + except HTTPError as e: + if e.response.status_code == 404: + raise ApiPermissionError( + "The calling user does not have permission to view the content", + reason=e, + ) + + raise + + return response + + def move_page( + self, + space_key, + page_id, + target_id=None, + target_title=None, + position="append", + ): + """ + Move page method + :param space_key: + :param page_id: + :param target_title: + :param target_id: + :param position: topLevel or append , above, below + :return: + """ + url = "/pages/movepage.action" + params = {"spaceKey": space_key, "pageId": page_id} + if target_title: + params["targetTitle"] = target_title + if target_id: + params["targetId"] = target_id + if position: + params["position"] = position + return self.post(url, params=params, headers=self.no_check_headers) + + def create_or_update_template( + self, + name, + body, + template_type="page", + template_id=None, + description=None, + labels=None, + space=None, + ): + """ + Creates a new or updates an existing content template. + + Note, blueprint templates cannot be created or updated via the REST API. + + If you provide a ``template_id`` then this method will update the template with the provided settings. + If no ``template_id`` is provided, then this method assumes you are creating a new template. + + :param str name: If creating, the name of the new template. If updating, the name to change + the template name to. Set to the current name if this field is not being updated. + :param dict body: This object is used when creating or updating content. + { + "storage": { + "value": "", + "representation": "view" + } + } + :param str template_type: OPTIONAL: The type of the new template. Default: "page". + :param str template_id: OPTIONAL: The ID of the template being updated. REQUIRED if updating a template. + :param str description: OPTIONAL: A description of the new template. Max length 255. + :param list labels: OPTIONAL: Labels for the new template. An array like: + [ + { + "prefix": "", + "name": "", + "id": "", + "label": "", + } + ] + :param dict space: OPTIONAL: The key for the space of the new template. Only applies to space templates. + If not specified, the template will be created as a global template. + :return: + """ + data = {"name": name, "templateType": template_type, "body": body} + + if description: + data["description"] = description + + if labels: + data["labels"] = labels + + if space: + data["space"] = {"key": space} + + if template_id: + data["templateId"] = template_id + return self.put("rest/api/template", data=json.dumps(data)) + + return self.post("rest/api/template", json=data) + + @deprecated(version="3.7.0", reason="Use get_content_template()") + def get_template_by_id(self, template_id): + """ + Get user template by id. Experimental API + Use case is get template body and create page from that + """ + url = f"rest/experimental/template/{template_id}" + + try: + response = self.get(url) + except HTTPError as e: + if e.response.status_code == 403: + # 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 view the content", + reason=e, + ) + + raise + return response + + def get_content_template(self, template_id): + """ + Get a content template. + + This includes information about the template, like the name, the space or blueprint + that the template is in, the body of the template, and more. + :param str template_id: The ID of the content template to be returned + :return: + """ + url = f"rest/api/template/{template_id}" + + try: + response = self.get(url) + except HTTPError as e: + if e.response.status_code == 403: + # 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 view the content", + reason=e, + ) + + raise + + return response + + @deprecated(version="3.7.0", reason="Use get_blueprint_templates()") + def get_all_blueprints_from_space(self, space, start=0, limit=None, expand=None): + """ + Get all users blueprints from space. Experimental API + :param space: Space Key + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by + fixed system limits. Default: 20 + :param expand: OPTIONAL: expand e.g. body + """ + url = "rest/experimental/template/blueprint" + params = {} + if space: + params["spaceKey"] = space + if start: + params["start"] = start + if limit: + params["limit"] = limit + if expand: + params["expand"] = expand + + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 403: + raise ApiPermissionError( + "The calling user does not have permission to view the content", + reason=e, + ) + + raise + + return response.get("results") or [] + + def get_blueprint_templates(self, space=None, start=0, limit=None, expand=None): + """ + Gets all templates provided by blueprints. + + Use this method to retrieve all global blueprint templates or all blueprint templates in a space. + :param space: OPTIONAL: The key of the space to be queried for templates. If ``space`` is not + specified, global blueprint templates will be returned. + :param start: OPTIONAL: The starting index of the returned templates. Default: None (0). + :param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by + fixed system limits. Default: 25 + :param expand: OPTIONAL: A multi-value parameter indicating which properties of the template to expand. + """ + url = "rest/api/template/blueprint" + params = {} + if space: + params["spaceKey"] = space + if start: + params["start"] = start + if limit: + params["limit"] = limit + if expand: + params["expand"] = expand + + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 403: + raise ApiPermissionError( + "The calling user does not have permission to view the content", + reason=e, + ) + + raise + + return response.get("results") or [] + + @deprecated(version="3.7.0", reason="Use get_content_templates()") + def get_all_templates_from_space(self, space, start=0, limit=None, expand=None): + """ + Get all users templates from space. Experimental API + ref: https://docs.atlassian.com/atlassian-confluence/1000.73.0/com/atlassian/confluence/plugins/restapi\ + /resources/TemplateResource.html + :param space: Space Key + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by + fixed system limits. Default: 20 + :param expand: OPTIONAL: expand e.g. body + """ + url = "rest/experimental/template/page" + params = {} + if space: + params["spaceKey"] = space + if start: + params["start"] = start + if limit: + params["limit"] = limit + if expand: + params["expand"] = expand + + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 403: + raise ApiPermissionError( + "The calling user does not have permission to view the content", + reason=e, + ) + raise + + return response.get("results") or [] + + def get_content_templates(self, space=None, start=0, limit=None, expand=None): + """ + Get all content templates. + Use this method to retrieve all global content templates or all content templates in a space. + :param space: OPTIONAL: The key of the space to be queried for templates. If ``space`` is not + specified, global templates will be returned. + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by + fixed system limits. Default: 25 + :param expand: OPTIONAL: A multi-value parameter indicating which properties of the template to expand. + e.g. ``body`` + """ + url = "rest/api/template/page" + params = {} + if space: + params["spaceKey"] = space + if start: + params["start"] = start + if limit: + params["limit"] = limit + if expand: + params["expand"] = expand + + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 403: + raise ApiPermissionError( + "The calling user does not have permission to view the content", + reason=e, + ) + + raise + + return response.get("results") or [] + + def remove_template(self, template_id): + """ + Deletes a template. + + This results in different actions depending on the type of template: + * If the template is a content template, it is deleted. + * If the template is a modified space-level blueprint template, it reverts to the template + inherited from the global-level blueprint template. + * If the template is a modified global-level blueprint template, it reverts to the default + global-level blueprint template. + Note: Unmodified blueprint templates cannot be deleted. + + :param str template_id: The ID of the template to be deleted. + :return: + """ + return self.delete(f"rest/api/template/{template_id}") + + def get_all_spaces( + self, + start=0, + limit=50, + expand=None, + space_type=None, + space_status=None, + ): + """ + Get all spaces with provided limit + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by + fixed system limits. Default: 500 + :param space_type: OPTIONAL: Filter the list of spaces returned by type (global, personal) + :param space_status: OPTIONAL: Filter the list of spaces returned by status (current, archived) + :param expand: OPTIONAL: additional info, e.g. metadata, icon, description, homepage + """ + url = "rest/api/space" + params = {} + if start: + params["start"] = start + if limit: + params["limit"] = limit + if expand: + params["expand"] = expand + if space_type: + params["type"] = space_type + if space_status: + params["status"] = space_status + return self.get(url, params=params) + + def archive_space(self, space_key): + """ + Archive space + :param space_key: + :return: + """ + url = f"rest/api/space/{space_key}/archive" + return self.put(url) + + def get_trashed_contents_by_space(self, space_key, cursor=None, expand=None, limit=100): + """ + Get trashed contents by space + :param space_key: + :param cursor: + :param expand: + :param limit: + :return: + """ + url = f"rest/api/space/{space_key}/content/trash" + params = {"limit": limit} + if cursor: + params["cursor"] = cursor + if expand: + params["expand"] = expand + return self.get(url, params=params) + + def remove_trashed_contents_by_space(self, space_key): + """ + Remove all content from the trash in the given space, + deleting them permanently.Example request URI: + :param space_key: + :return: + """ + url = f"rest/api/space/{space_key}/content/trash" + return self.delete(url) + + def add_comment(self, page_id, text): + """ + Add comment into page + :param page_id + :param text + """ + data = { + "type": "comment", + "container": {"id": page_id, "type": "page", "status": "current"}, + "body": self._create_body(text, "storage"), + } + + try: + response = self.post("rest/api/content/", data=data) + except HTTPError as e: + if e.response.status_code == 404: + raise ApiPermissionError( + "The calling user does not have permission to view the content", + reason=e, + ) + + raise + + return response + + def attach_content( + self, + content, + name, + content_type="application/binary", + page_id=None, + title=None, + space=None, + comment=None, + ): + """ + Attach (upload) a file to a page, if it exists it will update automatically the + version the new file and keep the old one. + :param title: The page name + :type title: ``str`` + :param space: The space name + :type space: ``str`` + :param page_id: The page id to which we would like to upload the file + :type page_id: ``str`` + :param name: The name of the attachment + :type name: ``str`` + :param content: Contains the content which should be uploaded + :type content: ``binary`` + :param content_type: Specify the HTTP content type. + The default is "application/binary" + :type content_type: ``str`` + :param comment: A comment describing this upload/file + :type comment: ``str`` + """ + page_id = self.get_page_id(space=space, title=title) if page_id is None else page_id + type = "attachment" + if page_id is not None: + comment = comment if comment else f"Uploaded {name}." + data = { + "type": type, + "fileName": name, + "contentType": content_type, + "comment": comment, + "minorEdit": "true", + } + headers = { + "X-Atlassian-Token": "no-check", + "Accept": "application/json", + } + path = f"rest/api/content/{page_id}/child/attachment" + # Check if there is already a file with the same name + attachments = self.get(path=path, headers=headers, params={"filename": name}) + if attachments.get("size"): + path = path + "/" + attachments["results"][0]["id"] + "/data" + + try: + response = self.post( + path=path, + data=data, + headers=headers, + files={"file": (name, content, content_type)}, + ) + except HTTPError as e: + if e.response.status_code == 403: + # Raise ApiError as the documented reason is ambiguous + raise ApiError( + "Attachments are disabled or the calling user does " + "not have permission to add attachments to this content", + reason=e, + ) + if e.response.status_code == 404: + # Raise ApiError as the documented reason is ambiguous + raise ApiError( + "The requested content is not found, the user does not have " + "permission to view it, or the attachments exceeds the maximum " + "configured attachment size", + reason=e, + ) + + raise + + return response + else: + log.warning("No 'page_id' found, not uploading attachments") + return None + + def attach_file( + self, + filename, + name=None, + content_type=None, + page_id=None, + title=None, + space=None, + comment=None, + ): + """ + Attach (upload) a file to a page, if it exists it will update automatically the + version the new file and keep the old one. + :param title: The page name + :type title: ``str`` + :param space: The space name + :type space: ``str`` + :param page_id: The page id to which we would like to upload the file + :type page_id: ``str`` + :param filename: The file to upload (Specifies the content) + :type filename: ``str`` + :param name: Specifies name of the attachment. This parameter is optional. + Is no name give the file name is used as name + :type name: ``str`` + :param content_type: Specify the HTTP content type. The default is + The default is "application/binary" + :type content_type: ``str`` + :param comment: A comment describing this upload/file + :type comment: ``str`` + """ + # get base name of the file to get the attachment from confluence. + if name is None: + name = os.path.basename(filename) + if content_type is None: + extension = os.path.splitext(filename)[-1] + content_type = self.content_types.get(extension, "application/binary") + + with open(filename, "rb") as infile: + return self.attach_content( + infile, + name, + content_type, + page_id=page_id, + title=title, + space=space, + comment=comment, + ) + + def download_attachments_from_page(self, page_id, path=None, start=0, limit=50, filename=None, to_memory=False): + """ + Downloads attachments from a Confluence page. Supports downloading all files or a specific file. + Files can either be saved to disk or returned as BytesIO objects for in-memory handling. + + :param page_id: str + The ID of the Confluence page to fetch attachments from. + :param path: str, optional + Directory where attachments will be saved. If None, defaults to the current working directory. + Ignored if `to_memory` is True. + :param start: int, optional + The start point for paginated attachment fetching. Default is 0. Ignored if `filename` is specified. + :param limit: int, optional + The maximum number of attachments to fetch per request. Default is 50. Ignored if `filename` is specified. + :param filename: str, optional + The name of a specific file to download. If provided, only this file will be fetched. + :param to_memory: bool, optional + If True, attachments are returned as a dictionary of {filename: BytesIO object}. + If False, files are written to the specified directory on disk. + :return: + - If `to_memory` is True, returns a dictionary {filename: BytesIO object}. + - If `to_memory` is False, returns a summary dict: {"attachments_downloaded": int, "path": str}. + :raises: + - FileNotFoundError: If the specified path does not exist. + - PermissionError: If there are permission issues with the specified path. + - requests.HTTPError: If the HTTP request to fetch an attachment fails. + - Exception: For any unexpected errors. + """ + # Default path to current working directory if not provided + if not to_memory and path is None: + path = os.getcwd() + + try: + # Fetch attachments based on the specified parameters + if filename: + # Fetch specific file by filename + attachments = self.get_attachments_from_content(page_id=page_id, filename=filename)["results"] + if not attachments: + return f"No attachment with filename '{filename}' found on the page." + else: + # Fetch all attachments with pagination + attachments = self.get_attachments_from_content(page_id=page_id, start=start, limit=limit)["results"] + if not attachments: + return "No attachments found on the page." + + # Prepare to handle downloads + downloaded_files = {} + for attachment in attachments: + file_name = attachment["title"] or attachment["id"] # Use attachment ID if title is unavailable + download_link = attachment["_links"]["download"] + # Fetch the file content + response = self.get(str(download_link), not_json_response=True) + + if to_memory: + # Store in BytesIO object + file_obj = io.BytesIO(response) + downloaded_files[file_name] = file_obj + else: + # Save file to disk + file_path = os.path.join(path, file_name) + with open(file_path, "wb") as file: + file.write(response) + + # Return results based on storage mode + if to_memory: + return downloaded_files + else: + return {"attachments_downloaded": len(attachments), "path": path} + except NotADirectoryError: + raise FileNotFoundError(f"The directory '{path}' does not exist.") + except PermissionError: + raise PermissionError(f"Permission denied when trying to save files to '{path}'.") + except requests.HTTPError as http_err: + raise requests.HTTPError( + f"HTTP error occurred while downloading attachments: {http_err}", + response=http_err.response, + request=http_err.request, + ) + except Exception as err: + raise Exception(f"An unexpected error occurred: {err}") + + def delete_attachment(self, page_id, filename, version=None): + """ + Remove completely a file if version is None or delete version + :param version: + :param page_id: file version + :param filename: + :return: + """ + params = {"pageId": page_id, "fileName": filename} + if version: + params["version"] = version + return self.post( + "json/removeattachment.action", + params=params, + headers=self.form_token_headers, + ) + + def delete_attachment_by_id(self, attachment_id, version): + """ + Remove completely a file if version is None or delete version + :param attachment_id: + :param version: file version + :return: + """ + if self.cloud: + url = f"rest/api/content/{attachment_id}/version/{version}" + else: + url = f"rest/experimental/content/{attachment_id}/version/{version}" + return self.delete(url) + + def remove_page_attachment_keep_version(self, page_id, filename, keep_last_versions): + """ + Keep last versions + :param filename: + :param page_id: + :param keep_last_versions: + :return: + """ + attachment = self.get_attachments_from_content(page_id=page_id, expand="version", filename=filename).get( + "results" + )[0] + attachment_versions = self.get_attachment_history(attachment.get("id")) + while len(attachment_versions) > keep_last_versions: + remove_version_attachment_number = attachment_versions[keep_last_versions].get("number") + self.delete_attachment_by_id( + attachment_id=attachment.get("id"), + version=remove_version_attachment_number, + ) + log.info( + "Removed oldest version for %s, now versions equal more than %s", + attachment.get("title"), + len(attachment_versions), + ) + attachment_versions = self.get_attachment_history(attachment.get("id")) + log.info("Kept versions %s for %s", keep_last_versions, attachment.get("title")) + + def get_attachment_history(self, attachment_id, limit=200, start=0): + """ + Get attachment history + :param attachment_id + :param limit + :param start + :return + """ + params = {"limit": limit, "start": start} + if self.cloud: + url = f"rest/api/content/{attachment_id}/version" + else: + url = f"rest/experimental/content/{attachment_id}/version" + return (self.get(url, params=params) or {}).get("results") + + # @todo prepare more attachments info + def get_attachments_from_content( + self, + page_id, + start=0, + limit=50, + expand=None, + filename=None, + media_type=None, + ): + """ + Get attachments for page + :param page_id: + :param start: + :param limit: + :param expand: + :param filename: + :param media_type: + :return: + """ + params = {} + if start: + params["start"] = start + if limit: + params["limit"] = limit + if expand: + params["expand"] = expand + if filename: + params["filename"] = filename + if media_type: + params["mediaType"] = media_type + url = f"rest/api/content/{page_id}/child/attachment" + + try: + response = self.get(url, params=params) + except HTTPError as 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 view the content", + reason=e, + ) + + raise + + return response + + def set_page_label(self, page_id, label): + """ + Set a label on the page + :param page_id: content_id format + :param label: label to add + :return: + """ + url = f"rest/api/content/{page_id}/label" + data = {"prefix": "global", "name": label} + + try: + response = self.post(path=url, data=data) + except HTTPError as 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 view the content", + reason=e, + ) + + raise + + return response + + def remove_page_label(self, page_id: str, label: str): + """ + Delete Confluence page label + :param page_id: content_id format + :param label: label name + :return: + """ + url = f"rest/api/content/{page_id}/label" + params = {"id": page_id, "name": label} + + try: + response = self.delete(path=url, params=params) + except HTTPError as e: + if e.response.status_code == 403: + raise ApiPermissionError( + "The user has view permission, " "but no edit permission to the content", + reason=e, + ) + if e.response.status_code == 404: + # Raise ApiError as the documented reason is ambiguous + raise ApiError( + "The content or label doesn't exist, " + "or the calling user doesn't have view permission to the content", + reason=e, + ) + + raise + + return response + + def history(self, page_id): + url = f"rest/api/content/{page_id}/history" + try: + response = self.get(url) + except HTTPError as 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 view the content", + reason=e, + ) + + raise + + return response + + def get_content_history(self, content_id): + return self.history(content_id) + + def get_content_history_by_version_number(self, content_id, version_number): + """ + Get content history by version number + :param content_id: + :param version_number: + :return: + """ + if self.cloud: + url = f"rest/api/content/{content_id}/version/{version_number}" + else: + url = f"rest/experimental/content/{content_id}/version/{version_number}" + return self.get(url) + + def remove_content_history(self, page_id, version_number): + """ + Remove content history. It works as experimental method + :param page_id: + :param version_number: version number + :return: + """ + if self.cloud: + url = f"rest/api/content/{page_id}/version/{version_number}" + else: + url = f"rest/experimental/content/{page_id}/version/{version_number}" + self.delete(url) + + def remove_page_history(self, page_id, version_number): + """ + Remove content history. It works as experimental method + :param page_id: + :param version_number: version number + :return: + """ + self.remove_content_history(page_id, version_number) + + def remove_content_history_in_cloud(self, page_id, version_id): + """ + Remove content history. It works in CLOUD + :param page_id: + :param version_id: + :return: + """ + url = f"rest/api/content/{page_id}/version/{version_id}" + self.delete(url) + + def remove_page_history_keep_version(self, page_id, keep_last_versions): + """ + Keep last versions + :param page_id: + :param keep_last_versions: + :return: + """ + page = self.get_page_by_id(page_id=page_id, expand="version") + page_number = page.get("version").get("number") + while page_number > keep_last_versions: + self.remove_page_history(page_id=page_id, version_number=1) + page = self.get_page_by_id(page_id=page_id, expand="version") + page_number = page.get("version").get("number") + log.info("Removed oldest version for %s, now it's %s", page.get("title"), page_number) + log.info("Kept versions %s for %s", keep_last_versions, page.get("title")) + + def has_unknown_attachment_error(self, page_id): + """ + Check has unknown attachment error on page + :param page_id: + :return: + """ + unknown_attachment_identifier = "plugins/servlet/confluence/placeholder/unknown-attachment" + result = self.get_page_by_id(page_id, expand="body.view") + if len(result) == 0: + return "" + body = ((result.get("body") or {}).get("view") or {}).get("value") or {} + if unknown_attachment_identifier in body: + return result.get("_links").get("base") + result.get("_links").get("tinyui") + return "" + + def is_page_content_is_already_updated(self, page_id, body, title=None): + """ + Compare content and check is already updated or not + :param page_id: Content ID for retrieve storage value + :param body: Body for compare it + :param title: Title to compare + :return: True if the same + """ + confluence_content = self.get_page_by_id(page_id) + if title: + current_title = confluence_content.get("title", None) + if title != current_title: + log.info("Title of %s is different", page_id) + return False + + if self.advanced_mode: + confluence_content = ( + (self.get_page_by_id(page_id, expand="body.storage").json() or {}).get("body") or {} + ).get("storage") or {} + else: + confluence_content = ((self.get_page_by_id(page_id, expand="body.storage") or {}).get("body") or {}).get( + "storage" + ) or {} + + confluence_body_content = confluence_content.get("value") + + if confluence_body_content: + # @todo move into utils + confluence_body_content = utils.symbol_normalizer(confluence_body_content) + + log.debug('Old Content: """%s"""', confluence_body_content) + log.debug('New Content: """%s"""', body) + + if confluence_body_content.strip().lower() == body.strip().lower(): + log.info("Content of %s is exactly the same", page_id) + return True + else: + log.info("Content of %s differs", page_id) + return False + + def update_existing_page( + self, + page_id, + title, + body, + type="page", + representation="storage", + minor_edit=False, + version_comment=None, + full_width=False, + ): + """Duplicate update_page. Left for the people who used it before. Use update_page instead""" + return self.update_page( + page_id=page_id, + title=title, + body=body, + type=type, + representation=representation, + minor_edit=minor_edit, + version_comment=version_comment, + full_width=full_width, + ) + + def update_page( + self, + page_id, + title, + body=None, + parent_id=None, + type="page", + representation="storage", + minor_edit=False, + version_comment=None, + always_update=False, + full_width=False, + ): + """ + Update page if already exist + :param page_id: + :param title: + :param body: + :param parent_id: + :param type: + :param representation: OPTIONAL: either Confluence 'storage' or 'wiki' markup format + :param minor_edit: Indicates whether to notify watchers about changes. + If False then notifications will be sent. + :param version_comment: Version comment + :param always_update: Whether always to update (suppress content check) + :param full_width: OPTIONAL: Default False + :return: + """ + # update current page + params = {"status": "current"} + log.info('Updating %s "%s" with %s', type, title, parent_id) + + if not always_update and body is not None and self.is_page_content_is_already_updated(page_id, body, title): + return self.get_page_by_id(page_id) + + try: + if self.advanced_mode: + version = self.history(page_id).json()["lastUpdated"]["number"] + 1 + else: + version = self.history(page_id)["lastUpdated"]["number"] + 1 + except (IndexError, TypeError) as e: + log.error("Can't find '%s' %s!", title, type) + log.debug(e) + return None + + data = { + "id": page_id, + "type": type, + "title": title, + "version": {"number": version, "minorEdit": minor_edit}, + "metadata": {"properties": {}}, + } + if body is not None: + data["body"] = self._create_body(body, representation) + + if parent_id: + data["ancestors"] = [{"type": "page", "id": parent_id}] + if version_comment: + data["version"]["message"] = version_comment + + if full_width is True: + data["metadata"]["properties"]["content-appearance-draft"] = {"value": "full-width"} + data["metadata"]["properties"]["content-appearance-published"] = {"value": "full-width"} + else: + data["metadata"]["properties"]["content-appearance-draft"] = {"value": "fixed-width"} + data["metadata"]["properties"]["content-appearance-published"] = {"value": "fixed-width"} + try: + response = self.put( + f"rest/api/content/{page_id}", + data=data, + params=params, + ) + except HTTPError as e: + if e.response.status_code == 400: + raise ApiValueError( + "No space or no content type, or setup a wrong version " + "type set to content, or status param is not draft and " + "status content is current", + reason=e, + ) + if e.response.status_code == 404: + raise ApiNotFoundError("Can not find draft with current content", reason=e) + + raise + + return response + + def _insert_to_existing_page( + self, + page_id, + title, + insert_body, + parent_id=None, + type="page", + representation="storage", + minor_edit=False, + version_comment=None, + top_of_page=False, + ): + """ + Insert body to a page if already exist + :param parent_id: + :param page_id: + :param title: + :param insert_body: + :param type: + :param representation: OPTIONAL: either Confluence 'storage' or 'wiki' markup format + :param minor_edit: Indicates whether to notify watchers about changes. + If False then notifications will be sent. + :param top_of_page: Option to add the content to the end of page body + :return: + """ + log.info('Updating %s "%s"', type, title) + # update current page + params = {"status": "current"} + + if self.is_page_content_is_already_updated(page_id, insert_body, title): + return self.get_page_by_id(page_id) + else: + version = self.history(page_id)["lastUpdated"]["number"] + 1 + previous_body = ( + (self.get_page_by_id(page_id, expand="body.storage").get("body") or {}).get("storage").get("value") + ) + previous_body = previous_body.replace("ó", "ó") + body = insert_body + previous_body if top_of_page else previous_body + insert_body + data = { + "id": page_id, + "type": type, + "title": title, + "body": self._create_body(body, representation), + "version": {"number": version, "minorEdit": minor_edit}, + } + + if parent_id: + data["ancestors"] = [{"type": "page", "id": parent_id}] + if version_comment: + data["version"]["message"] = version_comment + + try: + response = self.put( + f"rest/api/content/{page_id}", + data=data, + params=params, + ) + except HTTPError as e: + if e.response.status_code == 400: + raise ApiValueError( + "No space or no content type, or setup a wrong version " + "type set to content, or status param is not draft and " + "status content is current", + reason=e, + ) + if e.response.status_code == 404: + raise ApiNotFoundError("Can not find draft with current content", reason=e) + + raise + + return response + + def append_page( + self, + page_id, + title, + append_body, + parent_id=None, + type="page", + representation="storage", + minor_edit=False, + ): + """ + Append body to page if already exist + :param parent_id: + :param page_id: + :param title: + :param append_body: + :param type: + :param representation: OPTIONAL: either Confluence 'storage' or 'wiki' markup format + :param minor_edit: Indicates whether to notify watchers about changes. + If False then notifications will be sent. + :return: + """ + log.info('Updating %s "%s"', type, title) + + return self._insert_to_existing_page( + page_id, + title, + append_body, + parent_id=parent_id, + type=type, + representation=representation, + minor_edit=minor_edit, + top_of_page=False, + ) + + def prepend_page( + self, + page_id, + title, + prepend_body, + parent_id=None, + type="page", + representation="storage", + minor_edit=False, + ): + """ + Append body to page if already exist + :param parent_id: + :param page_id: + :param title: + :param prepend_body: + :param type: + :param representation: OPTIONAL: either Confluence 'storage' or 'wiki' markup format + :param minor_edit: Indicates whether to notify watchers about changes. + If False then notifications will be sent. + :return: + """ + log.info('Updating %s "%s"', type, title) + + return self._insert_to_existing_page( + page_id, + title, + prepend_body, + parent_id=parent_id, + type=type, + representation=representation, + minor_edit=minor_edit, + top_of_page=True, + ) + + def update_or_create( + self, + parent_id, + title, + body, + representation="storage", + minor_edit=False, + version_comment=None, + editor=None, + full_width=False, + ): + """ + Update page or create a page if it is not exists + :param parent_id: + :param title: + :param body: + :param representation: OPTIONAL: either Confluence 'storage' or 'wiki' markup format + :param minor_edit: Update page without notification + :param version_comment: Version comment + :param editor: OPTIONAL: v2 to be created in the new editor + :param full_width: OPTIONAL: Default is False + :return: + """ + space = self.get_page_space(parent_id) + + if self.page_exists(space, title): + page_id = self.get_page_id(space, title) + parent_id = parent_id if parent_id is not None else self.get_parent_content_id(page_id) + result = self.update_page( + parent_id=parent_id, + page_id=page_id, + title=title, + body=body, + representation=representation, + minor_edit=minor_edit, + version_comment=version_comment, + full_width=full_width, + ) + else: + result = self.create_page( + space=space, + parent_id=parent_id, + title=title, + body=body, + representation=representation, + editor=editor, + full_width=full_width, + ) + + log.info( + "You may access your page at: %s%s", + self.url, + ((result or {}).get("_links") or {}).get("tinyui"), + ) + return result + + def convert_wiki_to_storage(self, wiki): + """ + Convert to Confluence XHTML format from wiki style + :param wiki: + :return: + """ + data = {"value": wiki, "representation": "wiki"} + return self.post("rest/api/contentbody/convert/storage", data=data) + + def convert_storage_to_view(self, storage): + """ + Convert from Confluence XHTML format to view format + :param storage: + :return: + """ + data = {"value": storage, "representation": "storage"} + return self.post("rest/api/contentbody/convert/view", data=data) + + def set_page_property(self, page_id, data): + """ + Set the page (content) property e.g. add hash parameters + :param page_id: content_id format + :param data: data should be as json data + :return: + """ + url = f"rest/api/content/{page_id}/property" + json_data = data + + try: + response = self.post(path=url, data=json_data) + except HTTPError as e: + if e.response.status_code == 400: + raise ApiValueError( + "The given property has a different content id to the one in the " + "path, or the content already has a value with the given key, or " + "the value is missing, or the value is too long", + reason=e, + ) + if e.response.status_code == 403: + raise ApiPermissionError( + "The user does not have permission to " "edit the content with the given id", + reason=e, + ) + if e.response.status_code == 413: + raise ApiValueError("The value is too long", reason=e) + + raise + + return response + + def update_page_property(self, page_id, data): + """ + Update the page (content) property. + Use json data or independent keys + :param data: + :param page_id: content_id format + :data: property data in json format + :return: + """ + url = f"rest/api/content/{page_id}/property/{data.get('key')}" + try: + response = self.put(path=url, data=data) + except HTTPError as e: + if e.response.status_code == 400: + raise ApiValueError( + "The given property has a different content id to the one in the " + "path, or the content already has a value with the given key, or " + "the value is missing, or the value is too long", + reason=e, + ) + if e.response.status_code == 403: + raise ApiPermissionError( + "The user does not have permission to " "edit the content with the given id", + reason=e, + ) + if e.response.status_code == 404: + raise ApiNotFoundError( + "There is no content with the given id, or no property with the given key, " + "or if the calling user does not have permission to view the content.", + reason=e, + ) + if e.response.status_code == 409: + raise ApiConflictError( + "The given version is does not match the expected " "target version of the updated property", + reason=e, + ) + if e.response.status_code == 413: + raise ApiValueError("The value is too long", reason=e) + raise + return response + + def delete_page_property(self, page_id, page_property): + """ + Delete the page (content) property e.g. delete key of hash + :param page_id: content_id format + :param page_property: key of property + :return: + """ + url = f"rest/api/content/{page_id}/property/{str(page_property)}" + try: + response = self.delete(path=url) + except HTTPError as 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 view the content", + reason=e, + ) + + raise + + return response + + def get_page_property(self, page_id, page_property_key): + """ + Get the page (content) property e.g. get key of hash + :param page_id: content_id format + :param page_property_key: key of property + :return: + """ + url = f"rest/api/content/{page_id}/property/{str(page_property_key)}" + try: + response = self.get(path=url) + except HTTPError as 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 no property with the " + "given key, or the calling user does not have permission to view " + "the content", + reason=e, + ) + + raise + + return response + + def get_page_properties(self, page_id): + """ + Get the page (content) properties + :param page_id: content_id format + :return: get properties + """ + url = f"rest/api/content/{page_id}/property" + + try: + response = self.get(path=url) + except HTTPError as 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 view the content", + reason=e, + ) + + raise + + return response + + def get_page_ancestors(self, page_id): + """ + Provide the ancestors from the page (content) id + :param page_id: content_id format + :return: get properties + """ + url = f"rest/api/content/{page_id}?expand=ancestors" + + try: + response = self.get(path=url) + except HTTPError as e: + if e.response.status_code == 404: + raise ApiPermissionError( + "The calling user does not have permission to view the content", + reason=e, + ) + + raise + + return response.get("ancestors") + + def clean_all_caches(self): + """Clean all caches from cache management""" + headers = self.form_token_headers + return self.delete("rest/cacheManagement/1.0/cacheEntries", headers=headers) + + def clean_package_cache(self, cache_name="com.gliffy.cache.gon"): + """Clean caches from cache management + e.g. + com.gliffy.cache.gon + org.hibernate.cache.internal.StandardQueryCache_v5 + """ + headers = self.form_token_headers + data = {"cacheName": cache_name} + return self.delete("rest/cacheManagement/1.0/cacheEntries", data=data, headers=headers) + + def get_all_groups(self, start=0, limit=1000): + """ + Get all groups from Confluence User management + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of groups to return, this may be restricted by + fixed system limits. Default: 1000 + :return: + """ + url = f"rest/api/group?limit={limit}&start={start}" + + try: + response = self.get(url) + except HTTPError as e: + if e.response.status_code == 403: + raise ApiPermissionError( + "The calling user does not have permission to view groups", + reason=e, + ) + + raise + + return response.get("results") + + def create_group(self, name): + """ + Create a group by given group parameter + + :param name: str + :return: New group params + """ + url = "rest/api/admin/group" + data = {"name": name, "type": "group"} + return self.post(url, data=data) + + def remove_group(self, name): + """ + Delete a group by given group parameter + If you delete a group and content is restricted to that group, the content will be hidden from all users + + :param name: str + :return: + """ + log.info("Removing group: %s during Confluence remove_group method execution", name) + url = f"rest/api/admin/group/{name}" + + try: + response = self.delete(url) + except HTTPError as e: + if e.response.status_code == 404: + # Raise ApiError as the documented reason is ambiguous + raise ApiError( + "There is no group with the given name, " + "or the calling user does not have permission to delete it", + reason=e, + ) + raise + + return response + + def get_group_members(self, group_name="confluence-users", start=0, limit=1000, expand=None): + """ + Get a paginated collection of users in the given group + :param group_name + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of users to return, this may be restricted by + fixed system limits. Default: 1000 + :param expand: OPTIONAL: A comma separated list of properties to expand on the content. status + :return: + """ + url = f"rest/api/group/{group_name}/member?limit={limit}&start={start}&expand={expand}" + + try: + response = self.get(url) + except HTTPError as e: + if e.response.status_code == 403: + raise ApiPermissionError( + "The calling user does not have permission to view users", + reason=e, + ) + + raise + + return response.get("results") + + def get_all_members(self, group_name="confluence-users", expand=None): + """ + Get collection of all users in the given group + :param group_name + :param expand: OPTIONAL: A comma separated list of properties to expand on the content. status + :return: + """ + limit = 50 + flag = True + step = 0 + members = [] + while flag: + values = self.get_group_members( + group_name=group_name, + start=len(members), + limit=limit, + expand=expand, + ) + step += 1 + if len(values) == 0: + flag = False + else: + members.extend(values) + if not members: + print(f"Did not get members from {group_name} group, please check permissions or connectivity") + return members + + def get_space(self, space_key, expand="description.plain,homepage", params=None): + """ + Get information about a space through space key + :param space_key: The unique space key name + :param expand: OPTIONAL: additional info from description, homepage + :param params: OPTIONAL: dictionary of additional URL parameters + :return: Returns the space along with its ID + """ + url = f"rest/api/space/{space_key}" + params = params or {} + if expand: + params["expand"] = expand + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 404: + # Raise ApiError as the documented reason is ambiguous + raise ApiError( + "There is no space with the given key, " + "or the calling user does not have permission to view the space", + reason=e, + ) + raise + return response + + def get_space_content( + self, + space_key, + depth="all", + start=0, + limit=500, + content_type=None, + expand="body.storage", + ): + """ + Get space content. + You can specify which type of content want to receive, or get all content types. + Use expand to get specific content properties or page + :param content_type: + :param space_key: The unique space key name + :param depth: OPTIONAL: all|root + Gets all space pages or only root pages + :param start: OPTIONAL: The start point of the collection to return. Default: 0. + :param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by + fixed system limits. Default: 500 + :param expand: OPTIONAL: by default expands page body in confluence storage format. + See atlassian documentation for more information. + :return: Returns the space along with its ID + """ + + content_type = f"{'/' + content_type if content_type else ''}" + url = f"rest/api/space/{space_key}/content{content_type}" + params = { + "depth": depth, + "start": start, + "limit": limit, + } + if expand: + params["expand"] = expand + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 404: + # Raise ApiError as the documented reason is ambiguous + raise ApiError( + "There is no space with the given key, " + "or the calling user does not have permission to view the space", + reason=e, + ) + raise + return response + + def get_home_page_of_space(self, space_key): + """ + Get information about a space through space key + :param space_key: The unique space key name + :return: Returns homepage + """ + return self.get_space(space_key, expand="homepage").get("homepage") + + def create_space(self, space_key, space_name): + """ + Create space + :param space_key: + :param space_name: + :return: + """ + data = {"key": space_key, "name": space_name} + self.post("rest/api/space", data=data) + + def delete_space(self, space_key): + """ + Delete space + :param space_key: + :return: + """ + url = f"rest/api/space/{space_key}" + + try: + response = self.delete(url) + except HTTPError as e: + if e.response.status_code == 404: + # Raise ApiError as the documented reason is ambiguous + raise ApiError( + "There is no space with the given key, " + "or the calling user does not have permission to delete it", + reason=e, + ) + + raise + + return response + + def get_space_property(self, space_key, expand=None): + url = f"rest/api/space/{space_key}/property" + params = {} + if expand: + params["expand"] = expand + + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 404: + # Raise ApiError as the documented reason is ambiguous + raise ApiError( + "There is no space with the given key, " + "or the calling user does not have permission to view the space", + reason=e, + ) + + raise + + return response + + def get_user_details_by_username(self, username, expand=None): + """ + Get information about a user through username + :param username: The username + :param expand: OPTIONAL expand for get status of user. + Possible param is "status". Results are "Active, Deactivated" + :return: Returns the user details + """ + url = "rest/api/user" + params = {"username": username} + if expand: + params["expand"] = expand + + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 403: + raise ApiPermissionError( + "The calling user does not have permission to view users", + reason=e, + ) + if e.response.status_code == 404: + raise ApiNotFoundError( + "The user with the given username or userkey does not exist", + reason=e, + ) + + raise + + return response + + def get_user_details_by_accountid(self, accountid, expand=None): + """ + Get information about a user through accountid + :param accountid: The account id + :param expand: OPTIONAL expand for get status of user. + Possible param is "status". Results are "Active, Deactivated" + :return: Returns the user details + """ + url = "rest/api/user" + params = {"accountId": accountid} + if expand: + params["expand"] = expand + + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 403: + raise ApiPermissionError( + "The calling user does not have permission to view users", + reason=e, + ) + if e.response.status_code == 404: + raise ApiNotFoundError( + "The user with the given account does not exist", + reason=e, + ) + + raise + + return response + + def get_user_details_by_userkey(self, userkey, expand=None): + """ + Get information about a user through user key + :param userkey: The user key + :param expand: OPTIONAL expand for get status of user. + Possible param is "status". Results are "Active, Deactivated" + :return: Returns the user details + """ + url = "rest/api/user" + params = {"key": userkey} + if expand: + params["expand"] = expand + + try: + response = self.get(url, params=params) + except HTTPError as e: + if e.response.status_code == 403: + raise ApiPermissionError( + "The calling user does not have permission to view users", + reason=e, + ) + if e.response.status_code == 404: + raise ApiNotFoundError( + "The user with the given username or userkey does not exist", + reason=e, + ) + + raise + + return response + + def cql( + self, + cql, + start=0, + limit=None, + expand=None, + include_archived_spaces=None, + excerpt=None, + ): + """ + Get results from cql search result with all related fields + Search for entities in Confluence using the Confluence Query Language (CQL) + :param cql: + :param start: OPTIONAL: The start point of the collection to return. Default: 0. + :param limit: OPTIONAL: The limit of the number of issues to return, this may be restricted by + fixed system limits. Default by built-in method: 25 + :param excerpt: the excerpt strategy to apply to the result, one of : indexed, highlight, none. + This defaults to highlight + :param expand: OPTIONAL: the properties to expand on the search result, + this may cause database requests for some properties + :param include_archived_spaces: OPTIONAL: whether to include content in archived spaces in the result, + this defaults to false + :return: + """ + params = {} + if start is not None: + params["start"] = int(start) + if limit is not None: + params["limit"] = int(limit) + if cql is not None: + params["cql"] = cql + if expand is not None: + params["expand"] = expand + if include_archived_spaces is not None: + params["includeArchivedSpaces"] = include_archived_spaces + if excerpt is not None: + params["excerpt"] = excerpt + + try: + response = self.get("rest/api/search", params=params) + except HTTPError as e: + if e.response.status_code == 400: + raise ApiValueError("The query cannot be parsed", reason=e) + + raise + + return response + + def get_page_as_pdf(self, page_id): + """ + Export page as standard pdf exporter + :param page_id: Page ID + :return: PDF File + """ + headers = self.form_token_headers + url = f"spaces/flyingpdf/pdfpageexport.action?pageId={page_id}" + if self.api_version == "cloud" or self.cloud: + url = self.get_pdf_download_url_for_confluence_cloud(url) + if not url: + log.error("Failed to get download PDF url.") + raise ApiNotFoundError("Failed to export page as PDF", reason="Failed to get download PDF url.") + # To download the PDF file, the request should be with no headers of authentications. + return requests.get(url, timeout=75).content + return self.get(url, headers=headers, not_json_response=True) + + def get_page_as_word(self, page_id): + """ + Export page as standard word exporter. + :param page_id: Page ID + :return: Word File + """ + headers = self.form_token_headers + url = f"exportword?pageId={page_id}" + return self.get(url, headers=headers, not_json_response=True) + + def get_space_export(self, space_key: str, export_type: str) -> str: + """ + Export a Confluence space to a file of the specified type. + (!) This method was developed for Confluence Cloud and may not work with Confluence on-prem. + (!) This is an experimental method that does not trigger an officially supported REST endpoint. + It may break if Atlassian changes the space export front-end logic. + + :param space_key: The key of the space to export. + :param export_type: The type of export to perform. Valid values are: 'html', 'csv', 'xml', 'pdf'. + :return: The URL to download the exported file. + """ + + def get_atl_request(link: str): + # Nested function used to get atl_token used for XSRF protection. + # This is only applicable to html/csv/xml space exports + try: + response = self.get(link, advanced_mode=True) + parsed_html = BeautifulSoup(response.text, "html.parser") + atl_token = parsed_html.find("input", {"name": "atl_token"}).get("value") # type: ignore[union-attr] + return atl_token + except Exception as e: + raise ApiError("Problems with getting the atl_token for get_space_export method :", reason=e) + + # Checks if space_ke parameter is valid and if api_token has relevant permissions to space + self.get_space(space_key=space_key, expand="permissions") + + try: + log.info( + "Initiated experimental get_space_export method for export type: " + + export_type + + " from Confluence space: " + + space_key + ) + if export_type == "csv": + form_data = dict( + atl_token=get_atl_request(f"spaces/exportspacecsv.action?key={space_key}"), + exportType="TYPE_CSV", + contentOption="all", + includeComments="true", + confirm="Export", + ) + elif export_type == "html": + form_data = { + "atl_token": get_atl_request(f"spaces/exportspacehtml.action?key={space_key}"), + "exportType": "TYPE_HTML", + "contentOption": "visibleOnly", + "includeComments": "true", + "confirm": "Export", + } + elif export_type == "xml": + form_data = { + "atl_token": get_atl_request(f"spaces/exportspacexml.action?key={space_key}"), + "exportType": "TYPE_XML", + "contentOption": "all", + "includeComments": "true", + "confirm": "Export", + } + elif export_type == "pdf": + url = "spaces/flyingpdf/doflyingpdf.action?key=" + space_key + log.info("Initiated PDF space export") + return self.get_pdf_download_url_for_confluence_cloud(url) + else: + raise ValueError("Invalid export_type parameter value. Valid values are: 'html/csv/xml/pdf'") + url = self.url_joiner(url=self.url, path=f"spaces/doexportspace.action?key={space_key}") + + # Sending a POST request that triggers the space export. + response = self.session.post(url, headers=self.form_token_headers, data=form_data) + parsed_html = BeautifulSoup(response.text, "html.parser") + # Getting the poll URL to get the export progress status + try: + poll_url = cast("str", parsed_html.find("meta", {"name": "ajs-pollURI"}).get("content")) # type: ignore[union-attr] + except Exception as e: + raise ApiError("Problems with getting the poll_url for get_space_export method :", reason=e) + running_task = True + while running_task: + try: + progress_response = self.get(poll_url) or {} + log.info(f"Space {space_key} export status: {progress_response.get('message', 'None')}") + if progress_response is not {} and progress_response.get("complete"): + parsed_html = BeautifulSoup(progress_response.get("message"), "html.parser") + download_url = cast("str", parsed_html.find("a", {"class": "space-export-download-path"}).get("href")) # type: ignore + if self.url in download_url: + return download_url + else: + combined_url = self.url + download_url + # Ensure only one /wiki is included in the path + if combined_url.count("/wiki") > 1: + combined_url = combined_url.replace("/wiki/wiki", "/wiki") + return combined_url + time.sleep(30) + except Exception as e: + raise ApiError( + "Encountered error during space export status check from space " + space_key, reason=e + ) + + return "None" # Return None if the while loop does not return a value + except Exception as e: + raise ApiError("Encountered error during space export from space " + space_key, reason=e) + + def export_page(self, page_id): + """ + Alias method for export page as pdf + :param page_id: Page ID + :return: PDF File + """ + return self.get_page_as_pdf(page_id) + + def get_descendant_page_id(self, space, parent_id, title): + """ + Provide space, parent_id and title of the descendant page, it will return the descendant page_id + :param space: str + :param parent_id: int + :param title: str + :return: page_id of the page whose title is passed in argument + """ + page_id = "" + + url = f'rest/api/content/search?cql=parent={parent_id}%20AND%20space="{space}"' + + try: + response = self.get(url, {}) + except HTTPError as e: + if e.response.status_code == 400: + raise ApiValueError("The CQL is invalid or missing", reason=e) + + raise + + for each_page in response.get("results", []): + if each_page.get("title") == title: + page_id = each_page.get("id") + break + return page_id + + def reindex(self): + """ + It is not public method for reindex Confluence + :return: + """ + url = "rest/prototype/1/index/reindex" + return self.post(url) + + def reindex_get_status(self): + """ + Get reindex status of Confluence + :return: + """ + url = "rest/prototype/1/index/reindex" + return self.get(url) + + def health_check(self): + """ + Get health status + https://confluence.atlassian.com/jirakb/how-to-retrieve-health-check-results-using-rest-api-867195158.html + :return: + """ + # check as Troubleshooting & Support Tools Plugin + response = self.get("rest/troubleshooting/1.0/check/") + if not response: + # check as support tools + response = self.get("rest/supportHealthCheck/1.0/check/") + return response + + def synchrony_enable(self): + """ + Enable Synchrony + :return: + """ + headers = {"X-Atlassian-Token": "no-check"} + url = "rest/synchrony-interop/enable" + return self.post(url, headers=headers) + + def synchrony_disable(self): + """ + Disable Synchrony + :return: + """ + headers = {"X-Atlassian-Token": "no-check"} + url = "rest/synchrony-interop/disable" + return self.post(url, headers=headers) + + def check_access_mode(self): + return self.get("rest/api/accessmode") + + def anonymous(self): + """ + Get information about how anonymous is represented in confluence + :return: + """ + try: + response = self.get("rest/api/user/anonymous") + except HTTPError as e: + if e.response.status_code == 403: + raise ApiPermissionError( + "The calling user does not have permission to use Confluence", + reason=e, + ) + + raise + + return response + + def get_plugins_info(self): + """ + Provide plugins info + :return a json of installed plugins + """ + url = "rest/plugins/1.0/" + return self.get(url, headers=self.no_check_headers, trailing=True) + + def get_plugin_info(self, plugin_key): + """ + Provide plugin info + :return a json of installed plugins + """ + url = f"rest/plugins/1.0/{plugin_key}-key" + return self.get(url, headers=self.no_check_headers, trailing=True) + + def get_plugin_license_info(self, plugin_key): + """ + Provide plugin license info + :return a json specific License query + """ + url = f"rest/plugins/1.0/{plugin_key}-key/license" + return self.get(url, headers=self.no_check_headers, trailing=True) + + def upload_plugin(self, plugin_path): + """ + Provide plugin path for upload into Jira e.g. useful for auto deploy + :param plugin_path: + :return: + """ + files = {"plugin": open(plugin_path, "rb")} + upm_token = self.request( + method="GET", + path="rest/plugins/1.0/", + headers=self.no_check_headers, + trailing=True, + ).headers["upm-token"] + url = f"rest/plugins/1.0/?token={upm_token}" + return self.post(url, files=files, headers=self.no_check_headers) + + def disable_plugin(self, plugin_key): + """ + Disable a plugin + :param plugin_key: + :return: + """ + app_headers = { + "X-Atlassian-Token": "no-check", + "Content-Type": "application/vnd.atl.plugins+json", + } + url = f"rest/plugins/1.0/{plugin_key}-key" + data = {"status": "disabled"} + return self.put(url, data=data, headers=app_headers) + + def enable_plugin(self, plugin_key): + """ + Enable a plugin + :param plugin_key: + :return: + """ + app_headers = { + "X-Atlassian-Token": "no-check", + "Content-Type": "application/vnd.atl.plugins+json", + } + url = f"rest/plugins/1.0/{plugin_key}-key" + data = {"status": "enabled"} + return self.put(url, data=data, headers=app_headers) + + def delete_plugin(self, plugin_key): + """ + Delete plugin + :param plugin_key: + :return: + """ + url = f"rest/plugins/1.0/{plugin_key}-key" + return self.delete(url) + + def check_plugin_manager_status(self): + url = "rest/plugins/latest/safe-mode" + return self.request(method="GET", path=url, headers=self.safe_mode_headers) + + def update_plugin_license(self, plugin_key, raw_license): + """ + Update license for plugin + :param plugin_key: + :param raw_license: + :return: + """ + app_headers = { + "X-Atlassian-Token": "no-check", + "Content-Type": "application/vnd.atl.plugins+json", + } + url = f"/plugins/1.0/{plugin_key}/license" + data = {"rawLicense": raw_license} + return self.put(url, data=data, headers=app_headers) + + def check_long_tasks_result(self, start=None, limit=None, expand=None): + """ + Get result of long tasks + :param start: OPTIONAL: The start point of the collection to return. Default: None (0). + :param limit: OPTIONAL: The limit of the number of pages to return, this may be restricted by + fixed system limits. Default: 50 + :param expand: + :return: + """ + params = {} + if expand: + params["expand"] = expand + if start: + params["start"] = start + if limit: + params["limit"] = limit + return self.get("rest/api/longtask", params=params) + + def check_long_task_result(self, task_id, expand=None): + """ + Get result of long tasks + :param task_id: task id + :param expand: + :return: + """ + params = None + if expand: + params = {"expand": expand} + + try: + response = self.get(f"rest/api/longtask/{task_id}", params=params) + except HTTPError as e: + if e.response.status_code == 404: + # Raise ApiError as the documented reason is ambiguous + raise ApiError( + "There is no task with the given key, " "or the calling user does not have permission to view it", + reason=e, + ) + + raise + + return response + + def get_pdf_download_url_for_confluence_cloud(self, url): + """ + Confluence cloud does not return the PDF document when the PDF + export is initiated. Instead, it starts a process in the background + and provides a link to download the PDF once the process completes. + This functions polls the long-running task page and returns the + download url of the PDF. + This method is used in get_space_export() method for space-> PDF export. + :param url: URL to initiate PDF export + :return: Download url for PDF file + """ + try: + running_task = True + headers = self.form_token_headers + log.info("Initiate PDF export from Confluence Cloud") + response = self.get(url, headers=headers, not_json_response=True) + response_string = response.decode(encoding="utf-8", errors="ignore") + task_id = response_string.split('name="ajs-taskId" content="')[1].split('">')[0] + poll_url = f"/services/api/v1/task/{task_id}/progress" + while running_task: + log.info("Check if export task has completed.") + progress_response = self.get(poll_url) + percentage_complete = int(progress_response.get("progress", 0)) + task_state = progress_response.get("state") + if task_state == "FAILED": + log.error("PDF conversion not successful.") + return None + elif percentage_complete == 100: + running_task = False + log.info(f"Task completed - {task_state}") + log.debug("Extract task results to download PDF.") + task_result_url = progress_response.get("result") + else: + log.info(f"{percentage_complete}% - {task_state}") + time.sleep(3) + log.debug("Task successfully done, querying the task result for the download url") + # task result url starts with /wiki, remove it. + task_content = self.get(task_result_url[5:], not_json_response=True) + download_url = task_content.decode(encoding="utf-8", errors="strict") + log.debug("Successfully got the download url") + return download_url + except IndexError as e: + log.error(e) + return None + + def audit( + self, + start_date=None, + end_date=None, + start=None, + limit=None, + search_string=None, + ): + """ + Fetch a paginated list of AuditRecord instances dating back to a certain time + :param start_date: + :param end_date: + :param start: + :param limit: + :param search_string: + :return: + """ + url = "rest/api/audit" + params = {} + if start_date: + params["startDate"] = start_date + if end_date: + params["endDate"] = end_date + if start: + params["start"] = start + if limit: + params["limit"] = limit + if search_string: + params["searchString"] = search_string + return self.get(url, params=params) + + """ + ############################################################################################## + # Confluence whiteboards (cloud only!) # + ############################################################################################## + """ + + def create_whiteboard(self, spaceId, title=None, parentId=None): + url = "/api/v2/whiteboards" + data = {"spaceId": spaceId} + if title is not None: + data["title"] = title + if parentId is not None: + data["parentId"] = parentId + return self.post(url, data=data) + + def get_whiteboard(self, whiteboard_id): + try: + url = "/api/v2/whiteboards/%s" % (whiteboard_id) + return self.get(url) + except HTTPError as e: + # Default 404 error handling is ambiguous + if e.response.status_code == 404: + raise ApiValueError( + "Whiteboard not found. Check confluence instance url and/or if whiteboard id exists", reason=e + ) + + raise + + def delete_whiteboard(self, whiteboard_id): + try: + url = "/api/v2/whiteboards/%s" % (whiteboard_id) + return self.delete(url) + except HTTPError as e: + # # Default 404 error handling is ambiguous + if e.response.status_code == 404: + raise ApiValueError( + "Whiteboard not found. Check confluence instance url and/or if whiteboard id exists", reason=e + ) + + raise + + """ + ############################################################################################## + # Team Calendars REST API implements (https://jira.atlassian.com/browse/CONFSERVER-51003) # + ############################################################################################## + """ + + def team_calendars_get_sub_calendars(self, include=None, viewing_space_key=None, calendar_context=None): + """ + Get subscribed calendars + :param include: + :param viewing_space_key: + :param calendar_context: + :return: + """ + url = "rest/calendar-services/1.0/calendar/subcalendars" + params = {} + if include: + params["include"] = include + if viewing_space_key: + params["viewingSpaceKey"] = viewing_space_key + if calendar_context: + params["calendarContext"] = calendar_context + return self.get(url, params=params) + + def team_calendars_get_sub_calendars_watching_status(self, include=None): + url = "rest/calendar-services/1.0/calendar/subcalendars/watching/status" + params = {} + if include: + params["include"] = include + return self.get(url, params=params) + + def team_calendar_events(self, sub_calendar_id, start, end, user_time_zone_id=None): + """ + Get calendar event status + :param sub_calendar_id: + :param start: + :param end: + :param user_time_zone_id: + :return: + """ + url = "rest/calendar-services/1.0/calendar/events" + params = {} + if sub_calendar_id: + params["subCalendarId"] = sub_calendar_id + if user_time_zone_id: + params["userTimeZoneId"] = user_time_zone_id + if start: + params["start"] = start + if end: + params["end"] = end + return self.get(url, params=params) + + def get_mobile_parameters(self, username): + """ + Get mobile paramaters + :param username: + :return: + """ + url = f"rest/mobile/1.0/profile/{username}" + return self.get(url) + + def avatar_upload_for_user(self, user_key, data): + """ + + :param user_key: + :param data: json like {"avatarDataURI":"image in base64"} + :return: + """ + url = f"rest/user-profile/1.0/{user_key}/avatar/upload" + return self.post(url, data=data) + + def avatar_set_default_for_user(self, user_key): + """ + :param user_key: + :return: + """ + url = f"rest/user-profile/1.0/{user_key}/avatar/default" + return self.get(url) + + def add_user(self, email, fullname, username, password): + """ + That method related to creating user via json rpc for Confluence Server + """ + params = {"email": email, "fullname": fullname, "name": username} + url = "rpc/json-rpc/confluenceservice-v2" + data = { + "jsonrpc": "2.0", + "method": "addUser", + "params": [params, password], + } + self.post(url, data=data) + + def change_user_password(self, username, password): + """ + That method related to changing user password via json rpc for Confluence Server + """ + params = {"name": username} + url = "rpc/json-rpc/confluenceservice-v2" + data = { + "jsonrpc": "2.0", + "method": "changeUserPassword", + "params": [params, password], + } + self.post(url, data=data) + + def change_my_password(self, oldpass, newpass): + """ + That method related to changing calling user's own password via json rpc for Confluence Server + """ + url = "rpc/json-rpc/confluenceservice-v2" + data = { + "jsonrpc": "2.0", + "method": "changeMyPassword", + "params": [oldpass, newpass], + } + self.post(url, data=data) + + def add_user_to_group(self, username, group_name): + """ + Add given user to a group + + :param username: str - username of user to add to group + :param group_name: str - name of group to add user to + :return: Current state of the group + """ + url = f"rest/api/user/{username}/group/{group_name}" + return self.put(url) + + def remove_user_from_group(self, username, group_name): + """ + Remove the given {@link User} identified by username from the given {@link Group} identified by groupName. + This method is idempotent i.e. if the membership is not present then no action will be taken. + + :param username: str - username of user to add to group + :param group_name: str - name of group to add user to + :return: Current state of the group + """ + url = f"rest/api/user/{username}/group/{group_name}" + return self.delete(url) + + # Space Permissions + def get_all_space_permissions(self, space_key): + """ + Returns list of permissions granted to users and groups in the particular space. + :param space_key: + :return: + """ + url = f"rest/api/space/{space_key}/permissions" + return self.get(url) + + def set_permissions_to_multiple_items_for_space(self, space_key, user_key=None, group_name=None, operations=None): + """ + Sets permissions to multiple users/groups in the given space. + Request should contain all permissions that user/group/anonymous user will have in a given space. + If permission is absent in the request, but was granted before, it will be revoked. + If empty list of permissions passed to user/group/anonymous user, + then all their existing permissions will be revoked. + If user/group/anonymous user not mentioned in the request, their permissions will not be revoked. + + Maximum 40 different users/groups/anonymous user could be passed in the request. + :param space_key: + :param user_key: + :param group_name: + :param operations: + :return: + """ + url = f"rest/api/space/{space_key}/permissions" + params = [] + + if user_key: + params.append({"userKey": user_key, "operations": operations or []}) + + if group_name: + params.append({"groupName": group_name, "operations": operations or []}) + + if not user_key and not group_name: + params.append({"operations": operations or []}) + payload_json = json.dumps(params) + return self.post(url, data=payload_json) + + def get_permissions_granted_to_anonymous_for_space(self, space_key): + """ + Get permissions granted to anonymous user for the given space + :param space_key: + :return: + """ + url = f"rest/api/space/{space_key}/permissions/anonymous" + return self.get(url) + + def set_permissions_to_anonymous_for_space(self, space_key, operations=None): + """ + Grant permissions to anonymous user in the given space. Operation doesn't override existing permissions, + will only add those one that weren't granted before. Multiple permissions could be passed in one request. + Supported targetType and operationKey pairs: + + space read + space administer + space export + space restrict + space delete_own + space delete_mail + page create + page delete + blogpost create + blogpost delete + comment create + comment delete + attachment create + attachment delete + :param space_key: + :param operations: + :return: + """ + url = f"rest/api/space/{space_key}/permissions/anonymous" + data = {"operations": operations or []} + return self.put(url, data=data) + + def remove_permissions_from_anonymous_for_space(self, space_key, operations=None): + """ + Revoke permissions from anonymous user in the given space. + If anonymous user doesn't have permissions that we are trying to revoke, + those permissions will be silently skipped. Multiple permissions could be passed in one request. + Supported targetType and operationKey pairs: + + space read + space administer + space export + space restrict + space delete_own + space delete_mail + page create + page delete + blogpost create + blogpost delete + comment create + comment delete + attachment create + attachment delete + :param space_key: + :param operations: + :return: + """ + url = f"rest/api/space/{space_key}/permissions/anonymous/revoke" + data = {"operations": operations or []} + return self.put(url, data=data) + + def get_permissions_granted_to_group_for_space(self, space_key, group_name): + """ + Get permissions granted to group for the given space + :param space_key: + :param group_name: + :return: + """ + url = f"rest/api/space/{space_key}/permissions/group/{group_name}" + return self.get(url) + + def set_permissions_to_group_for_space(self, space_key, group_name, operations=None): + """ + Grant permissions to group in the given space. + Operation doesn't override existing permissions, will only add those one that weren't granted before. + Multiple permissions could be passed in one request. Supported targetType and operationKey pairs: + + space read + space administer + space export + space restrict + space delete_own + space delete_mail + page create + page delete + blogpost create + blogpost delete + comment create + comment delete + attachment create + attachment delete + :param space_key: + :param group_name: + :param operations: + :return: + """ + url = f"rest/api/space/{space_key}/permissions/group/{group_name}" + data = {"operations": operations or []} + return self.put(url, data=data) + + def remove_permissions_from_group_for_space(self, space_key, group_name, operations=None): + """ + Revoke permissions from a group in the given space. + If group doesn't have permissions that we are trying to revoke, + those permissions will be silently skipped. Multiple permissions could be passed in one request. + Supported targetType and operationKey pairs: + + space read + space administer + space export + space restrict + space delete_own + space delete_mail + page create + page delete + blogpost create + blogpost delete + comment create + comment delete + attachment create + attachment delete + :param space_key: + :param group_name: + :param operations: + :return: + """ + url = f"rest/api/space/{space_key}/permissions/group/{group_name}/revoke" + data = {"operations": operations or []} + return self.put(url, data=data) + + def get_permissions_granted_to_user_for_space(self, space_key, user_key): + """ + Get permissions granted to user for the given space + :param space_key: + :param user_key: + :return: + """ + url = f"rest/api/space/{space_key}/permissions/user/{user_key}" + return self.get(url) + + def set_permissions_to_user_for_space(self, space_key, user_key, operations=None): + """ + Grant permissions to user in the given space. + Operation doesn't override existing permissions, will only add those one that weren't granted before. + Multiple permissions could be passed in one request. Supported targetType and operationKey pairs: + + space read + space administer + space export + space restrict + space delete_own + space delete_mail + page create + page delete + blogpost create + blogpost delete + comment create + comment delete + attachment create + attachment delete + :param space_key: + :param user_key: + :param operations: + :return: + """ + url = f"rest/api/space/{space_key}/permissions/user/{user_key}" + data = {"operations": operations or []} + return self.put(url, data=data) + + def remove_permissions_from_user_for_space(self, space_key, user_key, operations=None): + """ + Revoke permissions from a user in the given space. + If user doesn't have permissions that we are trying to revoke, + those permissions will be silently skipped. Multiple permissions could be passed in one request. + Supported targetType and operationKey pairs: + + space read + space administer + space export + space restrict + space delete_own + space delete_mail + page create + page delete + blogpost create + blogpost delete + comment create + comment delete + attachment create + attachment delete + :param space_key: + :param user_key: + :param operations: + :return: + """ + url = f"rest/api/space/{space_key}/permissions/user/{user_key}/revoke" + data = {"operations": operations or []} + return self.put(url, params=data) + + def add_space_permissions( + self, + space_key, + subject_type, + subject_id, + operation_key, + operation_target, + ): + """ + Add permissions to a space + + :param space_key: str - key of space to add permissions to + :param subject_type: str - type of subject to add permissions for + :param subject_id: str - id of subject to add permissions for + :param operation_key: str - key of operation to add permissions for + :param operation_target: str - target of operation to add permissions for + :return: Current permissions of space + """ + url = f"rest/api/space/{space_key}/permission" + data = { + "subject": {"type": subject_type, "identifier": subject_id}, + "operation": {"key": operation_key, "target": operation_target}, + "_links": {}, + } + + return self.post(url, data=data, headers=self.experimental_headers) + + def remove_space_permission(self, space_key, user, permission): + """ + The JSON-RPC APIs for Confluence are provided here to help you browse and discover APIs you have access to. + JSON-RPC APIs operate differently than REST APIs. + To learn more about how to use these APIs, + please refer to the Confluence JSON-RPC documentation on Atlassian Developers. + """ + if self.api_version == "cloud" or self.cloud: + return {} + url = "rpc/json-rpc/confluenceservice-v2" + data = { + "jsonrpc": "2.0", + "method": "removePermissionFromSpace", + "id": 9, + "params": [permission, user, space_key], + } + return self.post(url, data=data).get("result") or {} + + def get_space_permissions(self, space_key): + """ + The JSON-RPC APIs for Confluence are provided here to help you browse and discover APIs you have access to. + JSON-RPC APIs operate differently than REST APIs. + To learn more about how to use these APIs, + please refer to the Confluence JSON-RPC documentation on Atlassian Developers. + """ + if self.api_version == "cloud" or self.cloud: + return self.get_space(space_key=space_key, expand="permissions") + url = "rpc/json-rpc/confluenceservice-v2" + data = { + "jsonrpc": "2.0", + "method": "getSpacePermissionSets", + "id": 7, + "params": [space_key], + } + return self.post(url, data=data).get("result") or {} + + def get_subtree_of_content_ids(self, page_id): + """ + Get subtree of page ids + :param page_id: + :return: Set of page ID + """ + output = list() + output.append(page_id) + children_pages = self.get_page_child_by_type(page_id) + for page in children_pages: + child_subtree = self.get_subtree_of_content_ids(page.get("id")) + if child_subtree: + output.extend([p for p in child_subtree]) + return set(output) + + def set_inline_tasks_checkbox(self, page_id, task_id, status): + """ + Set inline task element value + status is CHECKED or UNCHECKED + :return: + """ + url = f"rest/inlinetasks/1/task/{page_id}/{task_id}/" + data = {"status": status, "trigger": "VIEW_PAGE"} + return self.post(url, json=data) + + def get_jira_metadata(self, page_id): + """ + Get linked Jira ticket metadata + PRIVATE method + :param page_id: Page Id + :return: + """ + url = "rest/jira-metadata/1.0/metadata" + params = {"pageId": page_id} + return self.get(url, params=params) + + def get_jira_metadata_aggregated(self, page_id): + """ + Get linked Jira ticket aggregated metadata + PRIVATE method + :param page_id: Page Id + :return: + """ + url = "rest/jira-metadata/1.0/metadata/aggregate" + params = {"pageId": page_id} + return self.get(url, params=params) + + def clean_jira_metadata_cache(self, global_id): + """ + Clean cache for linked Jira app link + PRIVATE method + :param global_id: ID of Jira app link + :return: + """ + url = "rest/jira-metadata/1.0/metadata/cache" + params = {"globalId": global_id} + return self.delete(url, params=params) + + # Collaborative editing + def collaborative_editing_get_configuration(self): + """ + Get collaborative editing configuration + Related to the on-prem setup Confluence Data Center + :return: + """ + if self.cloud: + return ApiNotAcceptable + url = "rest/synchrony-interop/configuration" + return self.get(url, headers=self.no_check_headers) + + def collaborative_editing_disable(self): + """ + Disable collaborative editing + Related to the on-prem setup Confluence Data Center + :return: + """ + if self.cloud: + return ApiNotAcceptable + url = "rest/synchrony-interop/disable" + return self.post(url, headers=self.no_check_headers) + + def collaborative_editing_enable(self): + """ + Disable collaborative editing + Related to the on-prem setup Confluence Data Center + :return: + """ + if self.cloud: + return ApiNotAcceptable + url = "rest/synchrony-interop/enable" + return self.post(url, headers=self.no_check_headers) + + def collaborative_editing_restart(self): + """ + Disable collaborative editing + Related to the on-prem setup Confluence Data Center + :return: + """ + if self.cloud: + return ApiNotAcceptable + url = "rest/synchrony-interop/restart" + return self.post(url, headers=self.no_check_headers) + + def collaborative_editing_shared_draft_status(self): + """ + Status of collaborative editing + Related to the on-prem setup Confluence Data Center + :return: false or true parameter in json + { + "sharedDraftsEnabled": false + } + """ + if self.cloud: + return ApiNotAcceptable + url = "rest/synchrony-interop/status" + return self.get(url, headers=self.no_check_headers) + + def collaborative_editing_synchrony_status(self): + """ + Status of collaborative editing + Related to the on-prem setup Confluence Data Center + :return: stopped or running parameter in json + { + "status": "stopped" + } + """ + if self.cloud: + return ApiNotAcceptable + url = "rest/synchrony-interop/synchrony-status" + return self.get(url, headers=self.no_check_headers) + + def synchrony_get_configuration(self): + """ + Status of collaborative editing + Related to the on-prem setup Confluence Data Center + :return: + """ + if self.cloud: + return ApiNotAcceptable + url = "rest/synchrony/1.0/config/status" + return self.get(url, headers=self.no_check_headers) + + def synchrony_remove_draft(self, page_id): + """ + Status of collaborative editing + Related to the on-prem setup Confluence Data Center + :return: + """ + if self.cloud: + return ApiNotAcceptable + url = f"rest/synchrony/1.0/content/{page_id}/changes/unpublished" + return self.delete(url) + + def get_license_details(self): + """ + Returns the license detailed information + """ + url = "rest/license/1.0/license/details" + return self.get(url) + + def get_license_user_count(self): + """ + Returns the total used seats in the license + """ + url = "rest/license/1.0/license/userCount" + return self.get(url) + + def get_license_remaining(self): + """ + Returns the available license seats remaining + """ + url = "rest/license/1.0/license/remainingSeats" + return self.get(url) + + def get_license_max_users(self): + """ + Returns the license max users + """ + url = "rest/license/1.0/license/maxUsers" + return self.get(url) + + def raise_for_status(self, response): + """ + Checks the response for an error status and raises an exception with the error message provided by the server + :param response: + :return: + """ + if response.status_code == 401 and response.headers.get("Content-Type") != "application/json;charset=UTF-8": + raise HTTPError("Unauthorized (401)", response=response) + + if 400 <= response.status_code < 600: + try: + j = response.json() + error_msg = j["message"] + except Exception as e: + log.error(e) + response.raise_for_status() + else: + raise HTTPError(error_msg, response=response) diff --git a/atlassian/tempo/__init__.py b/atlassian/tempo/__init__.py new file mode 100644 index 000000000..60d263ca9 --- /dev/null +++ b/atlassian/tempo/__init__.py @@ -0,0 +1,14 @@ +# coding=utf-8 +""" +Tempo API client package for Atlassian Python API. + +This package provides both Cloud and Server implementations of the Tempo API. +""" + +from .cloud import Cloud as TempoCloud +from .server import Server as TempoServer + +__all__ = [ + "TempoCloud", + "TempoServer", +] diff --git a/atlassian/tempo/cloud/__init__.py b/atlassian/tempo/cloud/__init__.py new file mode 100644 index 000000000..41b3b6be1 --- /dev/null +++ b/atlassian/tempo/cloud/__init__.py @@ -0,0 +1,232 @@ +# coding=utf-8 + +from .base import TempoCloudBase + + +class Cloud(TempoCloudBase): + """ + Tempo Cloud REST API wrapper + """ + + def __init__(self, url="https://api.tempo.io/", *args, **kwargs): + # Set default API configuration for Tempo Cloud, but allow overrides + if "cloud" not in kwargs: + kwargs["cloud"] = True + if "api_version" not in kwargs: + kwargs["api_version"] = "1" + if "api_root" not in kwargs: + kwargs["api_root"] = "rest/tempo-timesheets/4" + super(Cloud, self).__init__(url, *args, **kwargs) + + # Account Management + def get_accounts(self, **kwargs): + """Get all accounts.""" + return self.get("accounts", **kwargs) + + def get_account(self, account_id, **kwargs): + """Get account by ID.""" + return self.get(f"accounts/{account_id}", **kwargs) + + def create_account(self, data, **kwargs): + """Create a new account.""" + return self.post("accounts", data=data, **kwargs) + + def update_account(self, account_id, data, **kwargs): + """Update an existing account.""" + return self.put(f"accounts/{account_id}", data=data, **kwargs) + + def delete_account(self, account_id, **kwargs): + """Delete an account.""" + return self.delete(f"accounts/{account_id}", **kwargs) + + # Worklog Management + def get_worklogs(self, **kwargs): + """Get all worklogs.""" + return self.get("worklogs", **kwargs) + + def get_worklog(self, worklog_id, **kwargs): + """Get worklog by ID.""" + return self.get(f"worklogs/{worklog_id}", **kwargs) + + def create_worklog(self, data, **kwargs): + """Create a new worklog.""" + return self.post("worklogs", data=data, **kwargs) + + def update_worklog(self, worklog_id, data, **kwargs): + """Update an existing worklog.""" + return self.put(f"worklogs/{worklog_id}", data=data, **kwargs) + + def delete_worklog(self, worklog_id, **kwargs): + """Delete a worklog.""" + return self.delete(f"worklogs/{worklog_id}", **kwargs) + + # Schedule Management + def get_schedules(self, **kwargs): + """Get all schedules.""" + return self.get("schedules", **kwargs) + + def get_schedule(self, schedule_id, **kwargs): + """Get schedule by ID.""" + return self.get(f"schedules/{schedule_id}", **kwargs) + + def create_schedule(self, data, **kwargs): + """Create a new schedule.""" + return self.post("schedules", data=data, **kwargs) + + def update_schedule(self, schedule_id, data, **kwargs): + """Update an existing schedule.""" + return self.put(f"schedules/{schedule_id}", data=data, **kwargs) + + def delete_schedule(self, schedule_id, **kwargs): + """Delete a schedule.""" + return self.delete(f"schedules/{schedule_id}", **kwargs) + + # User Management + def get_users(self, **kwargs): + """Get all users.""" + return self.get("users", **kwargs) + + def get_user(self, user_id, **kwargs): + """Get user by ID.""" + return self.get(f"users/{user_id}", **kwargs) + + def get_user_schedule(self, user_id, **kwargs): + """Get user's schedule.""" + return self.get(f"users/{user_id}/schedule", **kwargs) + + def get_user_worklogs(self, user_id, **kwargs): + """Get user's worklogs.""" + return self.get(f"users/{user_id}/worklogs", **kwargs) + + # Team Management + def get_teams(self, **kwargs): + """Get all teams.""" + return self.get("teams", **kwargs) + + def get_team(self, team_id, **kwargs): + """Get team by ID.""" + return self.get(f"teams/{team_id}", **kwargs) + + def create_team(self, data, **kwargs): + """Create a new team.""" + return self.post("teams", data=data, **kwargs) + + def update_team(self, team_id, data, **kwargs): + """Update an existing team.""" + return self.put(f"teams/{team_id}", data=data, **kwargs) + + def delete_team(self, team_id, **kwargs): + """Delete a team.""" + return self.delete(f"teams/{team_id}", **kwargs) + + def get_team_members(self, team_id, **kwargs): + """Get team members.""" + return self.get(f"teams/{team_id}/members", **kwargs) + + def add_team_member(self, team_id, user_id, **kwargs): + """Add member to team.""" + return self.post(f"teams/{team_id}/members", data={"userId": user_id}, **kwargs) + + def remove_team_member(self, team_id, user_id, **kwargs): + """Remove member from team.""" + return self.delete(f"teams/{team_id}/members/{user_id}", **kwargs) + + # Project Management + def get_projects(self, **kwargs): + """Get all projects.""" + return self.get("projects", **kwargs) + + def get_project(self, project_id, **kwargs): + """Get project by ID.""" + return self.get(f"projects/{project_id}", **kwargs) + + def get_project_worklogs(self, project_id, **kwargs): + """Get project worklogs.""" + return self.get(f"projects/{project_id}/worklogs", **kwargs) + + # Activity Management + def get_activities(self, **kwargs): + """Get all activities.""" + return self.get("activities", **kwargs) + + def get_activity(self, activity_id, **kwargs): + """Get activity by ID.""" + return self.get(f"activities/{activity_id}", **kwargs) + + def create_activity(self, data, **kwargs): + """Create a new activity.""" + return self.post("activities", data=data, **kwargs) + + def update_activity(self, activity_id, data, **kwargs): + """Update an existing activity.""" + return self.put(f"activities/{activity_id}", data=data, **kwargs) + + def delete_activity(self, activity_id, **kwargs): + """Delete an activity.""" + return self.delete(f"activities/{activity_id}", **kwargs) + + # Customer Management + def get_customers(self, **kwargs): + """Get all customers.""" + return self.get("customers", **kwargs) + + def get_customer(self, customer_id, **kwargs): + """Get customer by ID.""" + return self.get(f"customers/{customer_id}", **kwargs) + + def create_customer(self, data, **kwargs): + """Create a new customer.""" + return self.post("customers", data=data, **kwargs) + + def update_customer(self, customer_id, data, **kwargs): + """Update an existing customer.""" + return self.put(f"customers/{customer_id}", data=data, **kwargs) + + def delete_customer(self, customer_id, **kwargs): + """Delete a customer.""" + return self.delete(f"customers/{customer_id}", **kwargs) + + # Holiday Management + def get_holidays(self, **kwargs): + """Get all holidays.""" + return self.get("holidays", **kwargs) + + def get_holiday(self, holiday_id, **kwargs): + """Get holiday by ID.""" + return self.get(f"holidays/{holiday_id}", **kwargs) + + def create_holiday(self, data, **kwargs): + """Create a new holiday.""" + return self.post("holidays", data=data, **kwargs) + + def update_holiday(self, holiday_id, data, **kwargs): + """Update an existing holiday.""" + return self.put(f"holidays/{holiday_id}", data=data, **kwargs) + + def delete_holiday(self, holiday_id, **kwargs): + """Delete a holiday.""" + return self.delete(f"holidays/{holiday_id}", **kwargs) + + # Report Generation + def generate_report(self, report_type, params=None, **kwargs): + """Generate a report.""" + if params is None: + params = {} + return self.post(f"reports/{report_type}", data=params, **kwargs) + + def get_report_status(self, report_id, **kwargs): + """Get report generation status.""" + return self.get(f"reports/{report_id}/status", **kwargs) + + def download_report(self, report_id, **kwargs): + """Download a generated report.""" + return self.get(f"reports/{report_id}/download", **kwargs) + + # Utility Methods + def get_metadata(self, **kwargs): + """Get API metadata.""" + return self.get("metadata", **kwargs) + + def get_health(self, **kwargs): + """Get API health status.""" + return self.get("health", **kwargs) diff --git a/atlassian/tempo/cloud/base.py b/atlassian/tempo/cloud/base.py new file mode 100644 index 000000000..1a7d95cf9 --- /dev/null +++ b/atlassian/tempo/cloud/base.py @@ -0,0 +1,38 @@ +# coding=utf-8 +""" +Tempo Cloud API base class. +""" + +from ...rest_client import AtlassianRestAPI + + +class TempoCloudBase(AtlassianRestAPI): + """ + Base class for Tempo Cloud API operations. + """ + + def __init__(self, url, *args, **kwargs): + super(TempoCloudBase, self).__init__(url, *args, **kwargs) + + def _sub_url(self, url): + """ + Get the full url from a relative one. + + :param url: string: The sub url + :return: The absolute url + """ + return self.url_joiner(self.url, url) + + @property + def _new_session_args(self): + """ + Get the kwargs for new objects (session, root, version,...). + + :return: A dict with the kwargs for new objects + """ + return { + "session": self._session, + "cloud": self.cloud, + "api_root": self.api_root, + "api_version": self.api_version, + } diff --git a/atlassian/tempo/server/__init__.py b/atlassian/tempo/server/__init__.py new file mode 100644 index 000000000..2e066f740 --- /dev/null +++ b/atlassian/tempo/server/__init__.py @@ -0,0 +1,78 @@ +# coding=utf-8 + +from .base import TempoServerBase +from .accounts import Accounts +from .teams import Teams +from .planner import Planner +from .budgets import Budgets +from .timesheets import Timesheets +from .servlet import Servlet +from .events import Events + + +class Server(TempoServerBase): + """ + Tempo Server REST API wrapper + """ + + def __init__(self, url, *args, **kwargs): + # Set default API configuration for Tempo Server, but allow overrides + if "cloud" not in kwargs: + kwargs["cloud"] = False + if "api_version" not in kwargs: + kwargs["api_version"] = "1" + if "api_root" not in kwargs: + kwargs["api_root"] = "rest/tempo-core/1" + super(Server, self).__init__(url, *args, **kwargs) + + # Initialize specialized modules with reference to this instance + self.__accounts = Accounts(self._sub_url("accounts"), parent=self, **self._new_session_args) + self.__teams = Teams(self._sub_url("teams"), parent=self, **self._new_session_args) + self.__planner = Planner(self._sub_url("plans"), parent=self, **self._new_session_args) + self.__budgets = Budgets(self._sub_url("budgets"), parent=self, **self._new_session_args) + self.__timesheets = Timesheets(self._sub_url("timesheets"), parent=self, **self._new_session_args) + self.__servlet = Servlet(self._sub_url("worklogs"), parent=self, **self._new_session_args) + self.__events = Events(self._sub_url("events"), parent=self, **self._new_session_args) + + @property + def accounts(self): + """Property to access the accounts module.""" + return self.__accounts + + @property + def teams(self): + """Property to access the teams module.""" + return self.__teams + + @property + def planner(self): + """Property to access the planner module.""" + return self.__planner + + @property + def budgets(self): + """Property to access the budgets module.""" + return self.__budgets + + @property + def timesheets(self): + """Property to access the timesheets module.""" + return self.__timesheets + + @property + def servlet(self): + """Property to access the servlet module.""" + return self.__servlet + + @property + def events(self): + """Property to access the events module.""" + return self.__events + + def get_health(self, **kwargs): + """Get API health status.""" + return self.get("health", **kwargs) + + def get_metadata(self, **kwargs): + """Get API metadata.""" + return self.get("metadata", **kwargs) diff --git a/atlassian/tempo/server/accounts.py b/atlassian/tempo/server/accounts.py new file mode 100644 index 000000000..d40dd5a0d --- /dev/null +++ b/atlassian/tempo/server/accounts.py @@ -0,0 +1,38 @@ +# coding=utf-8 +""" +Tempo Server Accounts API module. +""" + +from .base import TempoServerBase + + +class Accounts(TempoServerBase): + """ + Tempo Server Accounts API client. + + Reference: https://www.tempo.io/server-api-documentation/accounts + """ + + def __init__(self, url, parent=None, *args, **kwargs): + super(Accounts, self).__init__(url, *args, **kwargs) + self.parent = parent + + def get_accounts(self, **kwargs): + """Get all accounts.""" + return self.parent.get("", **kwargs) + + def get_account(self, account_id, **kwargs): + """Get account by ID.""" + return self.parent.get(f"{account_id}", **kwargs) + + def create_account(self, data, **kwargs): + """Create a new account.""" + return self.parent.post("", data=data, **kwargs) + + def update_account(self, account_id, data, **kwargs): + """Update an existing account.""" + return self.parent.put(f"{account_id}", data=data, **kwargs) + + def delete_account(self, account_id, **kwargs): + """Delete an account.""" + return self.parent.delete(f"{account_id}", **kwargs) diff --git a/atlassian/tempo/server/base.py b/atlassian/tempo/server/base.py new file mode 100644 index 000000000..56f00940f --- /dev/null +++ b/atlassian/tempo/server/base.py @@ -0,0 +1,50 @@ +# coding=utf-8 +""" +Tempo Server API base class. +""" + +from ...rest_client import AtlassianRestAPI + + +class TempoServerBase(AtlassianRestAPI): + """ + Base class for Tempo Server API operations. + """ + + def __init__(self, url, *args, **kwargs): + super(TempoServerBase, self).__init__(url, *args, **kwargs) + + def _sub_url(self, url): + """ + Get the full url from a relative one. + + :param url: string: The sub url + :return: The absolute url + """ + return self.url_joiner(self.url, url) + + @property + def _new_session_args(self): + """ + Get the kwargs for new objects (session, root, version,...). + + :return: A dict with the kwargs for new objects + """ + return { + "session": self._session, + "cloud": self.cloud, + "api_root": self.api_root, + "api_version": self.api_version, + } + + def _call_parent_method(self, method_name, *args, **kwargs): + """ + Call a method on the parent class. + + :param method_name: The name of the method to call + :param args: Arguments to pass to the method + :param kwargs: Keyword arguments to pass to the method + :return: The result of the method call + """ + method = getattr(super(), method_name) + return method(*args, **kwargs) diff --git a/atlassian/tempo/server/budgets.py b/atlassian/tempo/server/budgets.py new file mode 100644 index 000000000..5e0ada942 --- /dev/null +++ b/atlassian/tempo/server/budgets.py @@ -0,0 +1,42 @@ +# coding=utf-8 +""" +Tempo Server Budgets API module. +""" + +from .base import TempoServerBase + + +class Budgets(TempoServerBase): + """ + Tempo Server Budgets API client. + + Reference: https://www.tempo.io/server-api-documentation/budgets + """ + + def __init__(self, url, parent=None, *args, **kwargs): + super(Budgets, self).__init__(url, *args, **kwargs) + self.parent = parent + + def get_budgets(self, **kwargs): + """Get all budgets.""" + return self.parent.get("", **kwargs) + + def get_budget(self, budget_id, **kwargs): + """Get budget by ID.""" + return self.parent.get(f"{budget_id}", **kwargs) + + def create_budget(self, data, **kwargs): + """Create a new budget.""" + return self.parent.post("", data=data, **kwargs) + + def update_budget(self, budget_id, data, **kwargs): + """Update an existing budget.""" + return self.parent.put(f"{budget_id}", data=data, **kwargs) + + def delete_budget(self, budget_id, **kwargs): + """Delete a budget.""" + return self.parent.delete(f"{budget_id}", **kwargs) + + def get_budget_allocations(self, budget_id, **kwargs): + """Get budget allocations.""" + return self.parent.get(f"{budget_id}/allocations", **kwargs) diff --git a/atlassian/tempo/server/events.py b/atlassian/tempo/server/events.py new file mode 100644 index 000000000..756dd640d --- /dev/null +++ b/atlassian/tempo/server/events.py @@ -0,0 +1,52 @@ +# coding=utf-8 +""" +Tempo Server Events API module. +""" + +from .base import TempoServerBase + + +class Events(TempoServerBase): + """ + Tempo Server Events API client. + + Reference: + - https://github.com/tempo-io/tempo-events-example/blob/master/README.md + - https://github.com/tempo-io/tempo-client-events/blob/master/README.md + """ + + def __init__(self, url, parent=None, *args, **kwargs): + super(Events, self).__init__(url, *args, **kwargs) + self.parent = parent + + def get_events(self, **kwargs): + """Get all events.""" + return self.parent.get("", **kwargs) + + def get_event(self, event_id, **kwargs): + """Get event by ID.""" + return self.parent.get(f"{event_id}", **kwargs) + + def create_event(self, data, **kwargs): + """Create a new event.""" + return self.parent.post("", data=data, **kwargs) + + def update_event(self, event_id, data, **kwargs): + """Update an existing event.""" + return self.parent.put(f"{event_id}", data=data, **kwargs) + + def delete_event(self, event_id, **kwargs): + """Delete an event.""" + return self.parent.delete(f"{event_id}", **kwargs) + + def get_event_subscriptions(self, **kwargs): + """Get event subscriptions.""" + return self.parent.get("subscriptions", **kwargs) + + def create_event_subscription(self, data, **kwargs): + """Create a new event subscription.""" + return self.parent.post("subscriptions", data=data, **kwargs) + + def delete_event_subscription(self, subscription_id, **kwargs): + """Delete an event subscription.""" + return self.parent.delete(f"subscriptions/{subscription_id}", **kwargs) diff --git a/atlassian/tempo/server/planner.py b/atlassian/tempo/server/planner.py new file mode 100644 index 000000000..1c70a0412 --- /dev/null +++ b/atlassian/tempo/server/planner.py @@ -0,0 +1,42 @@ +# coding=utf-8 +""" +Tempo Server Planner API module. +""" + +from .base import TempoServerBase + + +class Planner(TempoServerBase): + """ + Tempo Server Planner API client. + + Reference: https://www.tempo.io/server-api-documentation/planner + """ + + def __init__(self, url, parent=None, *args, **kwargs): + super(Planner, self).__init__(url, *args, **kwargs) + self.parent = parent + + def get_plans(self, **kwargs): + """Get all plans.""" + return self.parent.get("", **kwargs) + + def get_plan(self, plan_id, **kwargs): + """Get plan by ID.""" + return self.parent.get(f"{plan_id}", **kwargs) + + def create_plan(self, data, **kwargs): + """Create a new plan.""" + return self.parent.post("", data=data, **kwargs) + + def update_plan(self, plan_id, data, **kwargs): + """Update an existing plan.""" + return self.parent.put(f"{plan_id}", data=data, **kwargs) + + def delete_plan(self, plan_id, **kwargs): + """Delete a plan.""" + return self.parent.delete(f"{plan_id}", **kwargs) + + def get_plan_assignments(self, plan_id, **kwargs): + """Get plan assignments.""" + return self.parent.get(f"{plan_id}/assignments", **kwargs) diff --git a/atlassian/tempo/server/servlet.py b/atlassian/tempo/server/servlet.py new file mode 100644 index 000000000..314e7a6e9 --- /dev/null +++ b/atlassian/tempo/server/servlet.py @@ -0,0 +1,46 @@ +# coding=utf-8 +""" +Tempo Server Servlet API module. +""" + +from .base import TempoServerBase + + +class Servlet(TempoServerBase): + """ + Tempo Server Servlet API client. + + Reference: https://www.tempo.io/server-api-documentation/servlet + """ + + def __init__(self, url, parent=None, *args, **kwargs): + super(Servlet, self).__init__(url, *args, **kwargs) + self.parent = parent + + def get_worklogs(self, **kwargs): + """Get all worklogs.""" + return self.parent.get("", **kwargs) + + def get_worklog(self, worklog_id, **kwargs): + """Get worklog by ID.""" + return self.parent.get(f"{worklog_id}", **kwargs) + + def create_worklog(self, data, **kwargs): + """Create a new worklog.""" + return self.parent.post("", data=data, **kwargs) + + def update_worklog(self, worklog_id, data, **kwargs): + """Update an existing worklog.""" + return self.parent.put(f"{worklog_id}", data=data, **kwargs) + + def delete_worklog(self, worklog_id, **kwargs): + """Delete a worklog.""" + return self.parent.delete(f"{worklog_id}", **kwargs) + + def get_worklog_attributes(self, worklog_id, **kwargs): + """Get worklog attributes.""" + return self.parent.get(f"{worklog_id}/attributes", **kwargs) + + def update_worklog_attributes(self, worklog_id, data, **kwargs): + """Update worklog attributes.""" + return self.parent.put(f"{worklog_id}/attributes", data=data, **kwargs) diff --git a/atlassian/tempo/server/teams.py b/atlassian/tempo/server/teams.py new file mode 100644 index 000000000..37cc4449f --- /dev/null +++ b/atlassian/tempo/server/teams.py @@ -0,0 +1,50 @@ +# coding=utf-8 +""" +Tempo Server Teams API module. +""" + +from .base import TempoServerBase + + +class Teams(TempoServerBase): + """ + Tempo Server Teams API client. + + Reference: https://www.tempo.io/server-api-documentation/teams + """ + + def __init__(self, url, parent=None, *args, **kwargs): + super(Teams, self).__init__(url, *args, **kwargs) + self.parent = parent + + def get_teams(self, **kwargs): + """Get all teams.""" + return self.parent.get("", **kwargs) + + def get_team(self, team_id, **kwargs): + """Get team by ID.""" + return self.parent.get(f"{team_id}", **kwargs) + + def create_team(self, data, **kwargs): + """Create a new team.""" + return self.parent.post("", data=data, **kwargs) + + def update_team(self, team_id, data, **kwargs): + """Update an existing team.""" + return self.parent.put(f"{team_id}", data=data, **kwargs) + + def delete_team(self, team_id, **kwargs): + """Delete a team.""" + return self.parent.delete(f"{team_id}", **kwargs) + + def get_team_members(self, team_id, **kwargs): + """Get team members.""" + return self.parent.get(f"{team_id}/members", **kwargs) + + def add_team_member(self, team_id, user_id, **kwargs): + """Add member to team.""" + return self.parent.post(f"{team_id}/members", data={"userId": user_id}, **kwargs) + + def remove_team_member(self, team_id, user_id, **kwargs): + """Remove member from team.""" + return self.parent.delete(f"{team_id}/members/{user_id}", **kwargs) diff --git a/atlassian/tempo/server/timesheets.py b/atlassian/tempo/server/timesheets.py new file mode 100644 index 000000000..d59205c26 --- /dev/null +++ b/atlassian/tempo/server/timesheets.py @@ -0,0 +1,54 @@ +# coding=utf-8 +""" +Tempo Server Timesheets API module. +""" + +from .base import TempoServerBase + + +class Timesheets(TempoServerBase): + """ + Tempo Server Timesheets API client. + + Reference: https://www.tempo.io/server-api-documentation/timesheets + """ + + def __init__(self, url, parent=None, *args, **kwargs): + super(Timesheets, self).__init__(url, *args, **kwargs) + self.parent = parent + + def get_timesheets(self, **kwargs): + """Get all timesheets.""" + return self.parent.get("", **kwargs) + + def get_timesheet(self, timesheet_id, **kwargs): + """Get timesheet by ID.""" + return self.parent.get(f"{timesheet_id}", **kwargs) + + def create_timesheet(self, data, **kwargs): + """Create a new timesheet.""" + return self.parent.post("", data=data, **kwargs) + + def update_timesheet(self, timesheet_id, data, **kwargs): + """Update an existing timesheet.""" + return self.parent.put(f"{timesheet_id}", data=data, **kwargs) + + def delete_timesheet(self, timesheet_id, **kwargs): + """Delete a timesheet.""" + return self.parent.delete(f"{timesheet_id}", **kwargs) + + def get_timesheet_entries(self, timesheet_id, **kwargs): + """Get timesheet entries.""" + return self.parent.get(f"{timesheet_id}/entries", **kwargs) + + def submit_timesheet(self, timesheet_id, **kwargs): + """Submit a timesheet for approval.""" + return self.parent.post(f"{timesheet_id}/submit", **kwargs) + + def approve_timesheet(self, timesheet_id, **kwargs): + """Approve a timesheet.""" + return self.parent.post(f"{timesheet_id}/approve", **kwargs) + + def reject_timesheet(self, timesheet_id, reason, **kwargs): + """Reject a timesheet.""" + return self.parent.post(f"{timesheet_id}/reject", data={"reason": reason}, **kwargs) diff --git a/docs/tempo.rst b/docs/tempo.rst new file mode 100644 index 000000000..920c1496b --- /dev/null +++ b/docs/tempo.rst @@ -0,0 +1,620 @@ +Tempo API +========= + +The Tempo API client provides access to both Tempo Cloud and Tempo Server APIs +within Atlassian instances. + +Overview +-------- + +This implementation provides two main client types: + +- **TempoCloud**: For Tempo Cloud instances (hosted by Atlassian) +- **TempoServer**: For Tempo Server instances (self-hosted) + +The Tempo Cloud client is based on the official OpenAPI specification, +while the Tempo Server client provides access to various server-side API +modules. + +Installation +------------ + +The Tempo clients are included with the main atlassian-python-api package: + +.. code-block:: python + + from atlassian import TempoCloud, TempoServer + +Tempo Cloud +----------- + +The Tempo Cloud client provides access to Tempo's cloud-based time tracking +and project management capabilities. + +Basic Usage +----------- +Initialize the Tempo Cloud client: + +.. code-block:: python + + tempo = TempoCloud( + url="https://your-domain.atlassian.net", + token="your-tempo-api-token", + cloud=True + ) + +### Authentication + +Tempo Cloud uses API tokens for authentication. Generate a token from your +Tempo Cloud settings: + +1. Go to your Tempo Cloud instance +2. Navigate to **Settings** → **Integrations** → **API Keys** +3. Create a new API key +4. Use the generated token in your client initialization + +### API Endpoints + +The Tempo Cloud client provides access to the following endpoints: + +#### Account Management + +.. code-block:: python + + # Get all accounts + accounts = tempo.get_accounts() + + # Get specific account + account = tempo.get_account(account_id) + + # Create new account + new_account = tempo.create_account({ + "name": "Client Project", + "key": "CLIENT", + "status": "ACTIVE" + }) + + # Update account + updated_account = tempo.update_account(account_id, { + "name": "Updated Project Name" + }) + + # Delete account + tempo.delete_account(account_id) + +Worklog Management +------------------ +.. code-block:: python + + # Get all worklogs + worklogs = tempo.get_worklogs() + + # Get specific worklog + worklog = tempo.get_worklog(worklog_id) + + # Create new worklog + new_worklog = tempo.create_worklog({ + "issueKey": "PROJ-123", + "timeSpentSeconds": 3600, # 1 hour + "dateCreated": "2024-01-15", + "description": "Development work" + }) + + # Update worklog + updated_worklog = tempo.update_worklog(worklog_id, { + "timeSpentSeconds": 7200 # 2 hours + }) + + # Delete worklog + tempo.delete_worklog(worklog_id) + +Schedule Management +------------------- +.. code-block:: python + + # Get all schedules + schedules = tempo.get_schedules() + + # Get specific schedule + schedule = tempo.get_schedule(schedule_id) + + # Create new schedule + new_schedule = tempo.create_schedule({ + "name": "Flexible Schedule", + "type": "FLEXIBLE" + }) + + # Update schedule + updated_schedule = tempo.update_schedule(schedule_id, { + "name": "Updated Schedule Name" + }) + + # Delete schedule + tempo.delete_schedule(schedule_id) + +User Management +--------------- +.. code-block:: python + + # Get all users + users = tempo.get_users() + + # Get specific user + user = tempo.get_user(user_id) + + # Get user's schedule + user_schedule = tempo.get_user_schedule(user_id) + + # Get user's worklogs + user_worklogs = tempo.get_user_worklogs(user_id) + +Team Management +--------------- +.. code-block:: python + + # Get all teams + teams = tempo.get_teams() + + # Get specific team + team = tempo.get_team(team_id) + + # Create new team + new_team = tempo.create_team({ + "name": "Development Team", + "description": "Software development team" + }) + + # Update team + updated_team = tempo.update_team(team_id, { + "name": "Updated Team Name" + }) + + # Delete team + tempo.delete_team(team_id) + + # Get team members + team_members = tempo.get_team_members(team_id) + + # Add member to team + tempo.add_team_member(team_id, user_id) + + # Remove member from team + tempo.remove_team_member(team_id, user_id) + +Project Management +------------------ +.. code-block:: python + + # Get all projects + projects = tempo.get_projects() + + # Get specific project + project = tempo.get_project(project_id) + + # Get project worklogs + project_worklogs = tempo.get_project_worklogs(project_id) + +Activity Management +------------------- +.. code-block:: python + + # Get all activities + activities = tempo.get_activities() + + # Get specific activity + activity = tempo.get_activity(activity_id) + + # Create new activity + new_activity = tempo.create_activity({ + "name": "Code Review", + "description": "Reviewing code changes and providing feedback" + }) + + # Update activity + updated_activity = tempo.update_activity(activity_id, { + "name": "Updated Activity Name" + }) + + # Delete activity + tempo.delete_activity(activity_id) + +Customer Management +------------------- +.. code-block:: python + + # Get all customers + customers = tempo.get_customers() + + # Get specific customer + customer = tempo.get_customer(customer_id) + + # Create new customer + new_customer = tempo.create_customer({ + "name": "Acme Corporation", + "description": "Enterprise software client" + }) + + # Update customer + updated_customer = tempo.update_customer(customer_id, { + "name": "Updated Customer Name" + }) + + # Delete customer + tempo.delete_customer(customer_id) + +Holiday Management +------------------ +.. code-block:: python + + # Get all holidays + holidays = tempo.get_holidays() + + # Get specific holiday + holiday = tempo.get_holiday(holiday_id) + + # Create new holiday + new_holiday = tempo.create_holiday({ + "name": "Christmas Day", + "date": "2024-12-25", + "description": "Company holiday" + }) + + # Update holiday + updated_holiday = tempo.update_holiday(holiday_id, { + "name": "Updated Holiday Name" + }) + + # Delete holiday + tempo.delete_holiday(holiday_id) + +Report Generation +----------------- +.. code-block:: python + + # Generate report + report = tempo.generate_report("timesheet", { + "dateFrom": "2024-01-01", + "dateTo": "2024-01-31" + }) + + # Check report status + status = tempo.get_report_status(report_id) + + # Download report + report_data = tempo.download_report(report_id) + +Utility Methods +--------------- +.. code-block:: python + + # Get API metadata + metadata = tempo.get_metadata() + + # Check API health + health = tempo.get_health() + +Tempo Server +------------ + +The Tempo Server client provides access to various server-side API modules +for self-hosted Tempo instances. + +Basic Usage +----------- +Initialize the base Tempo Server client: + +.. code-block:: python + + tempo = TempoServer( + url="https://your-tempo-server.com", + token="your-tempo-api-token", + cloud=False + ) + +Specialized Client Classes +--------------------------- +For specific functionality, use the specialized client classes: + +Accounts API +------------ +.. code-block:: python + + from atlassian.tempo import TempoServerAccounts + + accounts_client = TempoServerAccounts( + url="https://your-tempo-server.com", + token="your-tempo-api-token" + ) + + # Get all accounts + accounts = accounts_client.get_accounts() + + # Create new account + new_account = accounts_client.create_account({ + "name": "New Account", + "key": "NEW" + }) + +Teams API +--------- +.. code-block:: python + + from atlassian.tempo import TempoServerTeams + + teams_client = TempoServerTeams( + url="https://your-tempo-server.com", + token="your-tempo-api-token" + ) + + # Get all teams + teams = teams_client.get_teams() + + # Create new team + new_team = teams_client.create_team({ + "name": "New Team", + "description": "Team description" + }) + + # Add member to team + teams_client.add_team_member(team_id, user_id) + +Planner API +----------- +.. code-block:: python + + from atlassian.tempo import TempoServerPlanner + + planner_client = TempoServerPlanner( + url="https://your-tempo-server.com", + token="your-tempo-api-token" + ) + + # Get all plans + plans = planner_client.get_plans() + + # Create new plan + new_plan = planner_client.create_plan({ + "name": "New Plan", + "description": "Plan description" + }) + + # Get plan assignments + assignments = planner_client.get_plan_assignments(plan_id) + +Budgets API +----------- +.. code-block:: python + + from atlassian.tempo import TempoServerBudgets + + budgets_client = TempoServerBudgets( + url="https://your-tempo-server.com", + token="your-tempo-api-token" + ) + + # Get all budgets + budgets = budgets_client.get_budgets() + + # Create new budget + new_budget = budgets_client.create_budget({ + "name": "New Budget", + "amount": 10000 + }) + + # Get budget allocations + allocations = budgets_client.get_budget_allocations(budget_id) + +Timesheets API +-------------- +.. code-block:: python + + from atlassian.tempo import TempoServerTimesheets + + timesheets_client = TempoServerTimesheets( + url="https://your-tempo-server.com", + token="your-tempo-api-token" + ) + + # Get all timesheets + timesheets = timesheets_client.get_timesheets() + + # Create new timesheet + new_timesheet = timesheets_client.create_timesheet({ + "name": "New Timesheet", + "userId": 1 + }) + + # Submit timesheet for approval + timesheets_client.submit_timesheet(timesheet_id) + + # Approve timesheet + timesheets_client.approve_timesheet(timesheet_id) + + # Reject timesheet + timesheets_client.reject_timesheet(timesheet_id, "Invalid entries") + +Servlet API +----------- +.. code-block:: python + + from atlassian.tempo import TempoServerServlet + + servlet_client = TempoServerServlet( + url="https://your-tempo-server.com", + token="your-tempo-api-token" + ) + + # Get all worklogs + worklogs = servlet_client.get_worklogs() + + # Create new worklog + new_worklog = servlet_client.create_worklog({ + "issueKey": "TEST-1", + "timeSpentSeconds": 3600 + }) + + # Get worklog attributes + attributes = servlet_client.get_worklog_attributes(worklog_id) + + # Update worklog attributes + servlet_client.update_worklog_attributes(worklog_id, { + "attribute1": "value1" + }) + +Events API +---------- +.. code-block:: python + + from atlassian.tempo import TempoServerEvents + + events_client = TempoServerEvents( + url="https://your-tempo-server.com", + token="your-tempo-api-token" + ) + + # Get all events + events = events_client.get_events() + + # Create new event + new_event = events_client.create_event({ + "type": "worklog_created", + "data": {"worklogId": 1} + }) + + # Get event subscriptions + subscriptions = events_client.get_event_subscriptions() + + # Create event subscription + new_subscription = events_client.create_event_subscription({ + "eventType": "worklog_created", + "url": "https://webhook.url" + }) + +API Configuration +----------------- +Both Cloud and Server clients support various configuration options: + +.. code-block:: python + + tempo = TempoCloud( + url="https://your-domain.atlassian.net", + token="your-tempo-api-token", + cloud=True, + timeout=75, + verify_ssl=True, + proxies={"http": "http://proxy:8080"}, + backoff_and_retry=True, + max_backoff_retries=1000 + ) + +Regional Endpoints +------------------ +For Tempo Cloud, you can use regional endpoints: + +- **Europe**: `https://api.eu.tempo.io` +- **Americas**: `https://api.us.tempo.io` +- **Global**: `https://api.tempo.io` + +.. code-block:: python + + # For European clients + tempo_eu = TempoCloud( + url="https://api.eu.tempo.io", + token="your-tempo-api-token" + ) + + # For American clients + tempo_us = TempoCloud( + url="https://api.us.tempo.io", + token="your-tempo-api-token" + ) + +Error Handling +-------------- +Both clients include proper error handling for common HTTP status codes: + +.. code-block:: python + + try: + accounts = tempo.get_accounts() + except Exception as e: + if "401" in str(e): + print("Authentication failed. Check your API token.") + elif "403" in str(e): + print("Access denied. Check your permissions.") + elif "404" in str(e): + print("Resource not found.") + elif "429" in str(e): + print("Rate limited. Wait before retrying.") + else: + print(f"Unexpected error: {e}") + +Rate Limiting +------------- +Both Tempo Cloud and Server APIs have rate limiting. The clients automatically +handle retries for rate-limited requests (status code 429). + +Examples +-------- + +See the `examples/tempo/` directory for complete working examples: + +- `tempo_cloud_example.py` - Cloud API usage +- `tempo_server_example.py` - Server API usage +- `tempo_integration_example.py` - Combined usage + +API Reference +------------- + +For detailed API documentation, visit: + +- **Tempo Cloud**: `Tempo Cloud API Documentation `_ +- **Tempo Server**: `Tempo Server API Documentation `_ + +Class Reference +--------------- + +.. autoclass:: atlassian.tempo.TempoCloud + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: atlassian.tempo.TempoServer + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: atlassian.tempo.TempoServerAccounts + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: atlassian.tempo.TempoServerTeams + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: atlassian.tempo.TempoServerPlanner + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: atlassian.tempo.TempoServerBudgets + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: atlassian.tempo.TempoServerTimesheets + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: atlassian.tempo.TempoServerServlet + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: atlassian.tempo.TempoServerEvents + :members: + :undoc-members: + :show-inheritance: diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..e80f36666 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,21 @@ +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --tb=short + --strict-markers + --disable-warnings + --cov=atlassian + --cov-report=term-missing + --cov-report=html + --cov-report=xml +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + integration: marks tests as integration tests + unit: marks tests as unit tests +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..1a69ee31f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +# coding=utf-8 +""" +Pytest configuration and fixtures for Tempo tests. +""" + +import pytest +from unittest.mock import Mock, patch + +# Import mockup server for testing +from .mockup import mockup_server + + +@pytest.fixture(scope="session") +def mock_server_url(): + """Fixture providing the mock server URL.""" + return mockup_server() + + +@pytest.fixture +def mock_response(): + """Fixture providing a mock response object.""" + mock_resp = Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"success": True} + mock_resp.text = '{"success": true}' + mock_resp.content = b'{"success": true}' + return mock_resp + + +@pytest.fixture +def mock_session(): + """Fixture providing a mock session object.""" + with patch("requests.Session") as mock_session: + mock_session.return_value.request.return_value = Mock() + yield mock_session + + +@pytest.fixture +def tempo_cloud_client(): + """Fixture providing a TempoCloud client for testing.""" + from atlassian.tempo import TempoCloud + + return TempoCloud(url="https://test.atlassian.net", token="test-token", cloud=True) + + +@pytest.fixture +def tempo_server_client(): + """Fixture providing a TempoServer client for testing.""" + from atlassian.tempo import TempoServer + + return TempoServer(url="https://test.atlassian.net", token="test-token", cloud=False) diff --git a/tests/test_tempo_cloud.py b/tests/test_tempo_cloud.py new file mode 100644 index 000000000..d063d0356 --- /dev/null +++ b/tests/test_tempo_cloud.py @@ -0,0 +1,458 @@ +# coding=utf-8 +""" +Test cases for Tempo Cloud API client. +""" + +import pytest +from unittest.mock import patch + +from atlassian.tempo import TempoCloud + + +@pytest.fixture +def tempo_cloud(): + """Fixture for TempoCloud client.""" + return TempoCloud(url="https://test.atlassian.net", token="test-token", cloud=True) + + +class TestTempoCloud: + """Test cases for TempoCloud client.""" + + def test_init_defaults(self): + """Test TempoCloud client initialization with default values.""" + tempo = TempoCloud(url="https://test.atlassian.net", token="test-token") + assert tempo.api_version == "1" + assert tempo.api_root == "rest/tempo-timesheets/4" + + def test_init_custom_values(self): + """Test TempoCloud client initialization with custom values.""" + tempo = TempoCloud( + url="https://test.atlassian.net", token="test-token", api_version="2", api_root="custom/api/root" + ) + assert tempo.api_version == "2" + assert tempo.api_root == "custom/api/root" + + # Account Management Tests + @patch.object(TempoCloud, "get") + def test_get_accounts(self, mock_get, tempo_cloud): + """Test get_accounts method.""" + mock_get.return_value = [{"id": 1, "name": "Test Account"}] + result = tempo_cloud.get_accounts() + mock_get.assert_called_once_with("accounts", **{}) + assert result == [{"id": 1, "name": "Test Account"}] + + @patch.object(TempoCloud, "get") + def test_get_account(self, mock_get, tempo_cloud): + """Test get_account method.""" + mock_get.return_value = {"id": 1, "name": "Test Account"} + result = tempo_cloud.get_account(1) + mock_get.assert_called_once_with("accounts/1", **{}) + assert result == {"id": 1, "name": "Test Account"} + + @patch.object(TempoCloud, "post") + def test_create_account(self, mock_post, tempo_cloud): + """Test create_account method.""" + account_data = {"name": "New Account", "key": "NEW"} + mock_post.return_value = {"id": 2, "name": "New Account", "key": "NEW"} + result = tempo_cloud.create_account(account_data) + mock_post.assert_called_once_with("accounts", data=account_data, **{}) + assert result == {"id": 2, "name": "New Account", "key": "NEW"} + + @patch.object(TempoCloud, "put") + def test_update_account(self, mock_put, tempo_cloud): + """Test update_account method.""" + account_data = {"name": "Updated Account"} + mock_put.return_value = {"id": 1, "name": "Updated Account"} + result = tempo_cloud.update_account(1, account_data) + mock_put.assert_called_once_with("accounts/1", data=account_data, **{}) + assert result == {"id": 1, "name": "Updated Account"} + + @patch.object(TempoCloud, "delete") + def test_delete_account(self, mock_delete, tempo_cloud): + """Test delete_account method.""" + mock_delete.return_value = {"success": True} + result = tempo_cloud.delete_account(1) + mock_delete.assert_called_once_with("accounts/1", **{}) + assert result == {"success": True} + + # Worklog Management Tests + @patch.object(TempoCloud, "get") + def test_get_worklogs(self, mock_get, tempo_cloud): + """Test get_worklogs method.""" + mock_get.return_value = [{"id": 1, "timeSpentSeconds": 3600}] + result = tempo_cloud.get_worklogs() + mock_get.assert_called_once_with("worklogs", **{}) + assert result == [{"id": 1, "timeSpentSeconds": 3600}] + + @patch.object(TempoCloud, "get") + def test_get_worklog(self, mock_get, tempo_cloud): + """Test get_worklog method.""" + mock_get.return_value = {"id": 1, "timeSpentSeconds": 3600} + result = tempo_cloud.get_worklog(1) + mock_get.assert_called_once_with("worklogs/1", **{}) + assert result == {"id": 1, "timeSpentSeconds": 3600} + + @patch.object(TempoCloud, "post") + def test_create_worklog(self, mock_post, tempo_cloud): + """Test create_worklog method.""" + worklog_data = {"issueKey": "TEST-1", "timeSpentSeconds": 3600} + mock_post.return_value = {"id": 2, "issueKey": "TEST-1", "timeSpentSeconds": 3600} + result = tempo_cloud.create_worklog(worklog_data) + mock_post.assert_called_once_with("worklogs", data=worklog_data, **{}) + assert result == {"id": 2, "issueKey": "TEST-1", "timeSpentSeconds": 3600} + + @patch.object(TempoCloud, "put") + def test_update_worklog(self, mock_put, tempo_cloud): + """Test update_worklog method.""" + worklog_data = {"timeSpentSeconds": 7200} + mock_put.return_value = {"id": 1, "timeSpentSeconds": 7200} + result = tempo_cloud.update_worklog(1, worklog_data) + mock_put.assert_called_once_with("worklogs/1", data=worklog_data, **{}) + assert result == {"id": 1, "timeSpentSeconds": 7200} + + @patch.object(TempoCloud, "delete") + def test_delete_worklog(self, mock_delete, tempo_cloud): + """Test delete_worklog method.""" + mock_delete.return_value = {"success": True} + result = tempo_cloud.delete_worklog(1) + mock_delete.assert_called_once_with("worklogs/1", **{}) + assert result == {"success": True} + + # Schedule Management Tests + @patch.object(TempoCloud, "get") + def test_get_schedules(self, mock_get, tempo_cloud): + """Test get_schedules method.""" + mock_get.return_value = [{"id": 1, "name": "Test Schedule"}] + result = tempo_cloud.get_schedules() + mock_get.assert_called_once_with("schedules", **{}) + assert result == [{"id": 1, "name": "Test Schedule"}] + + @patch.object(TempoCloud, "get") + def test_get_schedule(self, mock_get, tempo_cloud): + """Test get_schedule method.""" + mock_get.return_value = {"id": 1, "name": "Test Schedule"} + result = tempo_cloud.get_schedule(1) + mock_get.assert_called_once_with("schedules/1", **{}) + assert result == {"id": 1, "name": "Test Schedule"} + + @patch.object(TempoCloud, "post") + def test_create_schedule(self, mock_post, tempo_cloud): + """Test create_schedule method.""" + schedule_data = {"name": "New Schedule", "userId": 1} + mock_post.return_value = {"id": 2, "name": "New Schedule", "userId": 1} + result = tempo_cloud.create_schedule(schedule_data) + mock_post.assert_called_once_with("schedules", data=schedule_data, **{}) + assert result == {"id": 2, "name": "New Schedule", "userId": 1} + + @patch.object(TempoCloud, "put") + def test_update_schedule(self, mock_put, tempo_cloud): + """Test update_schedule method.""" + schedule_data = {"name": "Updated Schedule"} + mock_put.return_value = {"id": 1, "name": "Updated Schedule"} + result = tempo_cloud.update_schedule(1, schedule_data) + mock_put.assert_called_once_with("schedules/1", data=schedule_data, **{}) + assert result == {"id": 1, "name": "Updated Schedule"} + + @patch.object(TempoCloud, "delete") + def test_delete_schedule(self, mock_delete, tempo_cloud): + """Test delete_schedule method.""" + mock_delete.return_value = {"success": True} + result = tempo_cloud.delete_schedule(1) + mock_delete.assert_called_once_with("schedules/1", **{}) + assert result == {"success": True} + + # User Management Tests + @patch.object(TempoCloud, "get") + def test_get_users(self, mock_get, tempo_cloud): + """Test get_users method.""" + mock_get.return_value = [{"id": 1, "name": "Test User"}] + result = tempo_cloud.get_users() + mock_get.assert_called_once_with("users", **{}) + assert result == [{"id": 1, "name": "Test User"}] + + @patch.object(TempoCloud, "get") + def test_get_user(self, mock_get, tempo_cloud): + """Test get_user method.""" + mock_get.return_value = {"id": 1, "name": "Test User"} + result = tempo_cloud.get_user(1) + mock_get.assert_called_once_with("users/1", **{}) + assert result == {"id": 1, "name": "Test User"} + + @patch.object(TempoCloud, "get") + def test_get_user_schedule(self, mock_get, tempo_cloud): + """Test get_user_schedule method.""" + mock_get.return_value = {"id": 1, "userId": 1} + result = tempo_cloud.get_user_schedule(1) + mock_get.assert_called_once_with("users/1/schedule", **{}) + assert result == {"id": 1, "userId": 1} + + @patch.object(TempoCloud, "get") + def test_get_user_worklogs(self, mock_get, tempo_cloud): + """Test get_user_worklogs method.""" + mock_get.return_value = [{"id": 1, "userId": 1}] + result = tempo_cloud.get_user_worklogs(1) + mock_get.assert_called_once_with("users/1/worklogs", **{}) + assert result == [{"id": 1, "userId": 1}] + + # Team Management Tests + @patch.object(TempoCloud, "get") + def test_get_teams(self, mock_get, tempo_cloud): + """Test get_teams method.""" + mock_get.return_value = [{"id": 1, "name": "Test Team"}] + result = tempo_cloud.get_teams() + mock_get.assert_called_once_with("teams", **{}) + assert result == [{"id": 1, "name": "Test Team"}] + + @patch.object(TempoCloud, "get") + def test_get_team(self, mock_get, tempo_cloud): + """Test get_team method.""" + mock_get.return_value = {"id": 1, "name": "Test Team"} + result = tempo_cloud.get_team(1) + mock_get.assert_called_once_with("teams/1", **{}) + assert result == {"id": 1, "name": "Test Team"} + + @patch.object(TempoCloud, "post") + def test_create_team(self, mock_post, tempo_cloud): + """Test create_team method.""" + team_data = {"name": "New Team"} + mock_post.return_value = {"id": 2, "name": "New Team"} + result = tempo_cloud.create_team(team_data) + mock_post.assert_called_once_with("teams", data=team_data, **{}) + assert result == {"id": 2, "name": "New Team"} + + @patch.object(TempoCloud, "put") + def test_update_team(self, mock_put, tempo_cloud): + """Test update_team method.""" + team_data = {"name": "Updated Team"} + mock_put.return_value = {"id": 1, "name": "Updated Team"} + result = tempo_cloud.update_team(1, team_data) + mock_put.assert_called_once_with("teams/1", data=team_data, **{}) + assert result == {"id": 1, "name": "Updated Team"} + + @patch.object(TempoCloud, "delete") + def test_delete_team(self, mock_delete, tempo_cloud): + """Test delete_team method.""" + mock_delete.return_value = {"success": True} + result = tempo_cloud.delete_team(1) + mock_delete.assert_called_once_with("teams/1", **{}) + assert result == {"success": True} + + @patch.object(TempoCloud, "get") + def test_get_team_members(self, mock_get, tempo_cloud): + """Test get_team_members method.""" + mock_get.return_value = [{"id": 1, "name": "Member 1"}] + result = tempo_cloud.get_team_members(1) + mock_get.assert_called_once_with("teams/1/members", **{}) + assert result == [{"id": 1, "name": "Member 1"}] + + @patch.object(TempoCloud, "post") + def test_add_team_member(self, mock_post, tempo_cloud): + """Test add_team_member method.""" + mock_post.return_value = {"success": True} + result = tempo_cloud.add_team_member(1, 2) + mock_post.assert_called_once_with("teams/1/members", data={"userId": 2}, **{}) + assert result == {"success": True} + + @patch.object(TempoCloud, "delete") + def test_remove_team_member(self, mock_delete, tempo_cloud): + """Test remove_team_member method.""" + mock_delete.return_value = {"success": True} + result = tempo_cloud.remove_team_member(1, 2) + mock_delete.assert_called_once_with("teams/1/members/2", **{}) + assert result == {"success": True} + + # Project Management Tests + @patch.object(TempoCloud, "get") + def test_get_projects(self, mock_get, tempo_cloud): + """Test get_projects method.""" + mock_get.return_value = [{"id": 1, "name": "Test Project"}] + result = tempo_cloud.get_projects() + mock_get.assert_called_once_with("projects", **{}) + assert result == [{"id": 1, "name": "Test Project"}] + + @patch.object(TempoCloud, "get") + def test_get_project(self, mock_get, tempo_cloud): + """Test get_project method.""" + mock_get.return_value = {"id": 1, "name": "Test Project"} + result = tempo_cloud.get_project(1) + mock_get.assert_called_once_with("projects/1", **{}) + assert result == {"id": 1, "name": "Test Project"} + + @patch.object(TempoCloud, "get") + def test_get_project_worklogs(self, mock_get, tempo_cloud): + """Test get_project_worklogs method.""" + mock_get.return_value = [{"id": 1, "projectId": 1}] + result = tempo_cloud.get_project_worklogs(1) + mock_get.assert_called_once_with("projects/1/worklogs", **{}) + assert result == [{"id": 1, "projectId": 1}] + + # Activity Management Tests + @patch.object(TempoCloud, "get") + def test_get_activities(self, mock_get, tempo_cloud): + """Test get_activities method.""" + mock_get.return_value = [{"id": 1, "name": "Test Activity"}] + result = tempo_cloud.get_activities() + mock_get.assert_called_once_with("activities", **{}) + assert result == [{"id": 1, "name": "Test Activity"}] + + @patch.object(TempoCloud, "get") + def test_get_activity(self, mock_get, tempo_cloud): + """Test get_activity method.""" + mock_get.return_value = {"id": 1, "name": "Test Activity"} + result = tempo_cloud.get_activity(1) + mock_get.assert_called_once_with("activities/1", **{}) + assert result == {"id": 1, "name": "Test Activity"} + + @patch.object(TempoCloud, "post") + def test_create_activity(self, mock_post, tempo_cloud): + """Test create_activity method.""" + activity_data = {"name": "New Activity"} + mock_post.return_value = {"id": 2, "name": "New Activity"} + result = tempo_cloud.create_activity(activity_data) + mock_post.assert_called_once_with("activities", data=activity_data, **{}) + assert result == {"id": 2, "name": "New Activity"} + + @patch.object(TempoCloud, "put") + def test_update_activity(self, mock_put, tempo_cloud): + """Test update_activity method.""" + activity_data = {"name": "Updated Activity"} + mock_put.return_value = {"id": 1, "name": "Updated Activity"} + result = tempo_cloud.update_activity(1, activity_data) + mock_put.assert_called_once_with("activities/1", data=activity_data, **{}) + assert result == {"id": 1, "name": "Updated Activity"} + + @patch.object(TempoCloud, "delete") + def test_delete_activity(self, mock_delete, tempo_cloud): + """Test delete_activity method.""" + mock_delete.return_value = {"success": True} + result = tempo_cloud.delete_activity(1) + mock_delete.assert_called_once_with("activities/1", **{}) + assert result == {"success": True} + + # Customer Management Tests + @patch.object(TempoCloud, "get") + def test_get_customers(self, mock_get, tempo_cloud): + """Test get_customers method.""" + mock_get.return_value = [{"id": 1, "name": "Test Customer"}] + result = tempo_cloud.get_customers() + mock_get.assert_called_once_with("customers", **{}) + assert result == [{"id": 1, "name": "Test Customer"}] + + @patch.object(TempoCloud, "get") + def test_get_customer(self, mock_get, tempo_cloud): + """Test get_customer method.""" + mock_get.return_value = {"id": 1, "name": "Test Customer"} + result = tempo_cloud.get_customer(1) + mock_get.assert_called_once_with("customers/1", **{}) + assert result == {"id": 1, "name": "Test Customer"} + + @patch.object(TempoCloud, "post") + def test_create_customer(self, mock_post, tempo_cloud): + """Test create_customer method.""" + customer_data = {"name": "New Customer"} + mock_post.return_value = {"id": 2, "name": "New Customer"} + result = tempo_cloud.create_customer(customer_data) + mock_post.assert_called_once_with("customers", data=customer_data, **{}) + assert result == {"id": 2, "name": "New Customer"} + + @patch.object(TempoCloud, "put") + def test_update_customer(self, mock_put, tempo_cloud): + """Test update_customer method.""" + customer_data = {"name": "Updated Customer"} + mock_put.return_value = {"id": 1, "name": "Updated Customer"} + result = tempo_cloud.update_customer(1, customer_data) + mock_put.assert_called_once_with("customers/1", data=customer_data, **{}) + assert result == {"id": 1, "name": "Updated Customer"} + + @patch.object(TempoCloud, "delete") + def test_delete_customer(self, mock_delete, tempo_cloud): + """Test delete_customer method.""" + mock_delete.return_value = {"success": True} + result = tempo_cloud.delete_customer(1) + mock_delete.assert_called_once_with("customers/1", **{}) + assert result == {"success": True} + + # Holiday Management Tests + @patch.object(TempoCloud, "get") + def test_get_holidays(self, mock_get, tempo_cloud): + """Test get_holidays method.""" + mock_get.return_value = [{"id": 1, "name": "Test Holiday"}] + result = tempo_cloud.get_holidays() + mock_get.assert_called_once_with("holidays", **{}) + assert result == [{"id": 1, "name": "Test Holiday"}] + + @patch.object(TempoCloud, "get") + def test_get_holiday(self, mock_get, tempo_cloud): + """Test get_holiday method.""" + mock_get.return_value = {"id": 1, "name": "Test Holiday"} + result = tempo_cloud.get_holiday(1) + mock_get.assert_called_once_with("holidays/1", **{}) + assert result == {"id": 1, "name": "Test Holiday"} + + @patch.object(TempoCloud, "post") + def test_create_holiday(self, mock_post, tempo_cloud): + """Test create_holiday method.""" + holiday_data = {"name": "New Holiday", "date": "2024-01-01"} + mock_post.return_value = {"id": 2, "name": "New Holiday", "date": "2024-01-01"} + result = tempo_cloud.create_holiday(holiday_data) + mock_post.assert_called_once_with("holidays", data=holiday_data, **{}) + assert result == {"id": 2, "name": "New Holiday", "date": "2024-01-01"} + + @patch.object(TempoCloud, "put") + def test_update_holiday(self, mock_put, tempo_cloud): + """Test update_holiday method.""" + holiday_data = {"name": "Updated Holiday"} + mock_put.return_value = {"id": 1, "name": "Updated Holiday"} + result = tempo_cloud.update_holiday(1, holiday_data) + mock_put.assert_called_once_with("holidays/1", data=holiday_data, **{}) + assert result == {"id": 1, "name": "Updated Holiday"} + + @patch.object(TempoCloud, "delete") + def test_delete_holiday(self, mock_delete, tempo_cloud): + """Test delete_holiday method.""" + mock_delete.return_value = {"success": True} + result = tempo_cloud.delete_holiday(1) + mock_delete.assert_called_once_with("holidays/1", **{}) + assert result == {"success": True} + + # Report Generation Tests + @patch.object(TempoCloud, "post") + def test_generate_report(self, mock_post, tempo_cloud): + """Test generate_report method.""" + mock_post.return_value = {"reportId": "123"} + result = tempo_cloud.generate_report("timesheet", {"dateFrom": "2024-01-01"}) + mock_post.assert_called_once_with("reports/timesheet", data={"dateFrom": "2024-01-01"}, **{}) + assert result == {"reportId": "123"} + + @patch.object(TempoCloud, "get") + def test_get_report_status(self, mock_get, tempo_cloud): + """Test get_report_status method.""" + mock_get.return_value = {"status": "completed"} + result = tempo_cloud.get_report_status("123") + mock_get.assert_called_once_with("reports/123/status", **{}) + assert result == {"status": "completed"} + + @patch.object(TempoCloud, "get") + def test_download_report(self, mock_get, tempo_cloud): + """Test download_report method.""" + mock_get.return_value = {"content": "report data"} + result = tempo_cloud.download_report("123") + mock_get.assert_called_once_with("reports/123/download", **{}) + assert result == {"content": "report data"} + + # Utility Methods Tests + @patch.object(TempoCloud, "get") + def test_get_metadata(self, mock_get, tempo_cloud): + """Test get_metadata method.""" + mock_get.return_value = {"version": "1.0.0"} + result = tempo_cloud.get_metadata() + mock_get.assert_called_once_with("metadata", **{}) + assert result == {"version": "1.0.0"} + + @patch.object(TempoCloud, "get") + def test_get_health(self, mock_get, tempo_cloud): + """Test get_health method.""" + mock_get.return_value = {"status": "healthy"} + result = tempo_cloud.get_health() + mock_get.assert_called_once_with("health", **{}) + assert result == {"status": "healthy"} diff --git a/tests/test_tempo_cloud_pytest.py b/tests/test_tempo_cloud_pytest.py new file mode 100644 index 000000000..35ebe8c47 --- /dev/null +++ b/tests/test_tempo_cloud_pytest.py @@ -0,0 +1,195 @@ +# coding=utf-8 +""" +Test cases for Tempo Cloud API client using pytest. +""" + +import pytest +from unittest.mock import patch + +from atlassian.tempo import TempoCloud + + +@pytest.fixture +def tempo_cloud(): + """Fixture for TempoCloud client.""" + return TempoCloud(url="https://test.atlassian.net", token="test-token", cloud=True) + + +class TestTempoCloud: + """Test cases for TempoCloud client.""" + + def test_init_defaults(self): + """Test TempoCloud client initialization with default values.""" + tempo = TempoCloud(url="https://test.atlassian.net", token="test-token") + assert tempo.api_version == "1" + assert tempo.api_root == "rest/tempo-timesheets/4" + + def test_init_custom_values(self): + """Test TempoCloud client initialization with custom values.""" + tempo = TempoCloud( + url="https://test.atlassian.net", token="test-token", api_version="2", api_root="custom/api/root" + ) + assert tempo.api_version == "2" + assert tempo.api_root == "custom/api/root" + + # Account Management Tests + @patch.object(TempoCloud, "get") + def test_get_accounts(self, mock_get, tempo_cloud): + """Test get_accounts method.""" + mock_get.return_value = [{"id": 1, "name": "Test Account"}] + result = tempo_cloud.get_accounts() + mock_get.assert_called_once_with("accounts", **{}) + assert result == [{"id": 1, "name": "Test Account"}] + + @patch.object(TempoCloud, "get") + def test_get_account(self, mock_get, tempo_cloud): + """Test get_account method.""" + mock_get.return_value = {"id": 1, "name": "Test Account"} + result = tempo_cloud.get_account(1) + mock_get.assert_called_once_with("accounts/1", **{}) + assert result == {"id": 1, "name": "Test Account"} + + @patch.object(TempoCloud, "post") + def test_create_account(self, mock_post, tempo_cloud): + """Test create_account method.""" + account_data = {"name": "New Account", "key": "NEW"} + mock_post.return_value = {"id": 2, "name": "New Account", "key": "NEW"} + result = tempo_cloud.create_account(account_data) + mock_post.assert_called_once_with("accounts", data=account_data, **{}) + assert result == {"id": 2, "name": "New Account", "key": "NEW"} + + @patch.object(TempoCloud, "put") + def test_update_account(self, mock_put, tempo_cloud): + """Test update_account method.""" + account_data = {"name": "Updated Account"} + mock_put.return_value = {"id": 1, "name": "Updated Account"} + result = tempo_cloud.update_account(1, account_data) + mock_put.assert_called_once_with("accounts/1", data=account_data, **{}) + assert result == {"id": 1, "name": "Updated Account"} + + @patch.object(TempoCloud, "delete") + def test_delete_account(self, mock_delete, tempo_cloud): + """Test delete_account method.""" + mock_delete.return_value = {"success": True} + result = tempo_cloud.delete_account(1) + mock_delete.assert_called_once_with("accounts/1", **{}) + assert result == {"success": True} + + # Worklog Management Tests + @patch.object(TempoCloud, "get") + def test_get_worklogs(self, mock_get, tempo_cloud): + """Test get_worklogs method.""" + mock_get.return_value = [{"id": 1, "timeSpentSeconds": 3600}] + result = tempo_cloud.get_worklogs() + mock_get.assert_called_once_with("worklogs", **{}) + assert result == [{"id": 1, "timeSpentSeconds": 3600}] + + @patch.object(TempoCloud, "get") + def test_get_worklog(self, mock_get, tempo_cloud): + """Test get_worklog method.""" + mock_get.return_value = {"id": 1, "timeSpentSeconds": 3600} + result = tempo_cloud.get_worklog(1) + mock_get.assert_called_once_with("worklogs/1", **{}) + assert result == {"id": 1, "timeSpentSeconds": 3600} + + @patch.object(TempoCloud, "post") + def test_create_worklog(self, mock_post, tempo_cloud): + """Test create_worklog method.""" + worklog_data = {"issueKey": "TEST-1", "timeSpentSeconds": 3600} + mock_post.return_value = {"id": 2, "issueKey": "TEST-1", "timeSpentSeconds": 3600} + result = tempo_cloud.create_worklog(worklog_data) + mock_post.assert_called_once_with("worklogs", data=worklog_data, **{}) + assert result == {"id": 2, "issueKey": "TEST-1", "timeSpentSeconds": 3600} + + @patch.object(TempoCloud, "put") + def test_update_worklog(self, mock_put, tempo_cloud): + """Test update_worklog method.""" + worklog_data = {"timeSpentSeconds": 7200} + mock_put.return_value = {"id": 1, "timeSpentSeconds": 7200} + result = tempo_cloud.update_worklog(1, worklog_data) + mock_put.assert_called_once_with("worklogs/1", data=worklog_data, **{}) + assert result == {"id": 1, "timeSpentSeconds": 7200} + + @patch.object(TempoCloud, "delete") + def test_delete_worklog(self, mock_delete, tempo_cloud): + """Test delete_worklog method.""" + mock_delete.return_value = {"success": True} + result = tempo_cloud.delete_worklog(1) + mock_delete.assert_called_once_with("worklogs/1", **{}) + assert result == {"success": True} + + # Team Management Tests + @patch.object(TempoCloud, "get") + def test_get_teams(self, mock_get, tempo_cloud): + """Test get_teams method.""" + mock_get.return_value = [{"id": 1, "name": "Test Team"}] + result = tempo_cloud.get_teams() + mock_get.assert_called_once_with("teams", **{}) + assert result == [{"id": 1, "name": "Test Team"}] + + @patch.object(TempoCloud, "post") + def test_create_team(self, mock_post, tempo_cloud): + """Test create_team method.""" + team_data = {"name": "New Team"} + mock_post.return_value = {"id": 2, "name": "New Team"} + result = tempo_cloud.create_team(team_data) + mock_post.assert_called_once_with("teams", data=team_data, **{}) + assert result == {"id": 2, "name": "New Team"} + + @patch.object(TempoCloud, "put") + def test_update_team(self, mock_put, tempo_cloud): + """Test update_team method.""" + team_data = {"name": "Updated Team"} + mock_put.return_value = {"id": 1, "name": "Updated Team"} + result = tempo_cloud.update_team(1, team_data) + mock_put.assert_called_once_with("teams/1", data=team_data, **{}) + assert result == {"id": 1, "name": "Updated Team"} + + @patch.object(TempoCloud, "delete") + def test_delete_team(self, mock_delete, tempo_cloud): + """Test delete_team method.""" + mock_delete.return_value = {"success": True} + result = tempo_cloud.delete_team(1) + mock_delete.assert_called_once_with("teams/1", **{}) + assert result == {"success": True} + + @patch.object(TempoCloud, "get") + def test_get_team_members(self, mock_get, tempo_cloud): + """Test get_team_members method.""" + mock_get.return_value = [{"id": 1, "name": "Member 1"}] + result = tempo_cloud.get_team_members(1) + mock_get.assert_called_once_with("teams/1/members", **{}) + assert result == [{"id": 1, "name": "Member 1"}] + + @patch.object(TempoCloud, "post") + def test_add_team_member(self, mock_post, tempo_cloud): + """Test add_team_member method.""" + mock_post.return_value = {"success": True} + result = tempo_cloud.add_team_member(1, 2) + mock_post.assert_called_once_with("teams/1/members", data={"userId": 2}, **{}) + assert result == {"success": True} + + @patch.object(TempoCloud, "delete") + def test_remove_team_member(self, mock_delete, tempo_cloud): + """Test remove_team_member method.""" + mock_delete.return_value = {"success": True} + result = tempo_cloud.remove_team_member(1, 2) + mock_delete.assert_called_once_with("teams/1/members/2", **{}) + assert result == {"success": True} + + # Utility Methods Tests + @patch.object(TempoCloud, "get") + def test_get_metadata(self, mock_get, tempo_cloud): + """Test get_metadata method.""" + mock_get.return_value = {"version": "1.0.0"} + result = tempo_cloud.get_metadata() + mock_get.assert_called_once_with("metadata", **{}) + assert result == {"version": "1.0.0"} + + @patch.object(TempoCloud, "get") + def test_get_health(self, mock_get, tempo_cloud): + """Test get_health method.""" + mock_get.return_value = {"status": "healthy"} + result = tempo_cloud.get_health() + mock_get.assert_called_once_with("health", **{}) + assert result == {"status": "healthy"} diff --git a/tests/test_tempo_server.py b/tests/test_tempo_server.py new file mode 100644 index 000000000..f52eed24f --- /dev/null +++ b/tests/test_tempo_server.py @@ -0,0 +1,229 @@ +# coding=utf-8 +""" +Test cases for Tempo Server API clients. +""" + +import unittest +from unittest.mock import patch + +from atlassian.tempo import TempoServer + + +class TestTempoServer(unittest.TestCase): + """Test cases for base TempoServer client.""" + + def setUp(self): + """Set up test fixtures.""" + self.tempo = TempoServer(url="https://test.atlassian.net", token="test-token", cloud=False) + + def test_init_defaults(self): + """Test TempoServer client initialization with default values.""" + tempo = TempoServer(url="https://test.atlassian.net", token="test-token") + self.assertEqual(tempo.api_version, "1") + self.assertEqual(tempo.api_root, "rest/tempo-core/1") + + def test_init_custom_values(self): + """Test TempoServer client initialization with custom values.""" + tempo = TempoServer( + url="https://test.atlassian.net", token="test-token", api_version="2", api_root="custom/api/root" + ) + self.assertEqual(tempo.api_version, "2") + self.assertEqual(tempo.api_root, "custom/api/root") + + def test_specialized_modules_exist(self): + """Test that specialized modules are properly initialized.""" + self.assertIsNotNone(self.tempo.accounts) + self.assertIsNotNone(self.tempo.teams) + self.assertIsNotNone(self.tempo.planner) + self.assertIsNotNone(self.tempo.budgets) + self.assertIsNotNone(self.tempo.timesheets) + self.assertIsNotNone(self.tempo.servlet) + self.assertIsNotNone(self.tempo.events) + + @patch.object(TempoServer, "get") + def test_get_health(self, mock_get): + """Test get_health method.""" + mock_get.return_value = {"status": "healthy"} + result = self.tempo.get_health() + mock_get.assert_called_once_with("health", **{}) + self.assertEqual(result, {"status": "healthy"}) + + @patch.object(TempoServer, "get") + def test_get_metadata(self, mock_get): + """Test get_metadata method.""" + mock_get.return_value = {"version": "1.0.0"} + result = self.tempo.get_metadata() + mock_get.assert_called_once_with("metadata", **{}) + self.assertEqual(result, {"version": "1.0.0"}) + + +class TestTempoServerAccounts(unittest.TestCase): + """Test cases for TempoServer accounts module.""" + + def setUp(self): + """Set up test fixtures.""" + self.tempo = TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_accounts(self, mock_get): + """Test get_accounts method.""" + mock_get.return_value = [{"id": 1, "name": "Test Account"}] + result = self.tempo.accounts.get_accounts() + mock_get.assert_called_once_with("", **{}) + self.assertEqual(result, [{"id": 1, "name": "Test Account"}]) + + @patch.object(TempoServer, "get") + def test_get_account(self, mock_get): + """Test get_account method.""" + mock_get.return_value = {"id": 1, "name": "Test Account"} + result = self.tempo.accounts.get_account(1) + mock_get.assert_called_once_with("1", **{}) + self.assertEqual(result, {"id": 1, "name": "Test Account"}) + + +class TestTempoServerTeams(unittest.TestCase): + """Test cases for TempoServer teams module.""" + + def setUp(self): + """Set up test fixtures.""" + self.tempo = TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_teams(self, mock_get): + """Test get_teams method.""" + mock_get.return_value = [{"id": 1, "name": "Test Team"}] + result = self.tempo.teams.get_teams() + mock_get.assert_called_once_with("", **{}) + self.assertEqual(result, [{"id": 1, "name": "Test Team"}]) + + @patch.object(TempoServer, "get") + def test_get_team(self, mock_get): + """Test get_team method.""" + mock_get.return_value = {"id": 1, "name": "Test Team"} + result = self.tempo.teams.get_team(1) + mock_get.assert_called_once_with("1", **{}) + self.assertEqual(result, {"id": 1, "name": "Test Team"}) + + +class TestTempoServerPlanner(unittest.TestCase): + """Test cases for TempoServer planner module.""" + + def setUp(self): + """Set up test fixtures.""" + self.tempo = TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_plans(self, mock_get): + """Test get_plans method.""" + mock_get.return_value = [{"id": 1, "name": "Test Plan"}] + result = self.tempo.planner.get_plans() + mock_get.assert_called_once_with("", **{}) + self.assertEqual(result, [{"id": 1, "name": "Test Plan"}]) + + @patch.object(TempoServer, "get") + def test_get_plan(self, mock_get): + """Test get_plan method.""" + mock_get.return_value = {"id": 1, "name": "Test Plan"} + result = self.tempo.planner.get_plan(1) + mock_get.assert_called_once_with("1", **{}) + self.assertEqual(result, {"id": 1, "name": "Test Plan"}) + + +class TestTempoServerBudgets(unittest.TestCase): + """Test cases for TempoServer budgets module.""" + + def setUp(self): + """Set up test fixtures.""" + self.tempo = TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_budgets(self, mock_get): + """Test get_budgets method.""" + mock_get.return_value = [{"id": 1, "name": "Test Budget"}] + result = self.tempo.budgets.get_budgets() + mock_get.assert_called_once_with("", **{}) + self.assertEqual(result, [{"id": 1, "name": "Test Budget"}]) + + @patch.object(TempoServer, "get") + def test_get_budget(self, mock_get): + """Test get_budget method.""" + mock_get.return_value = {"id": 1, "name": "Test Budget"} + result = self.tempo.budgets.get_budget(1) + mock_get.assert_called_once_with("1", **{}) + self.assertEqual(result, {"id": 1, "name": "Test Budget"}) + + +class TestTempoServerTimesheets(unittest.TestCase): + """Test cases for TempoServer timesheets module.""" + + def setUp(self): + """Set up test fixtures.""" + self.tempo = TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_timesheets(self, mock_get): + """Test get_timesheets method.""" + mock_get.return_value = [{"id": 1, "name": "Test Timesheet"}] + result = self.tempo.timesheets.get_timesheets() + mock_get.assert_called_once_with("", **{}) + self.assertEqual(result, [{"id": 1, "name": "Test Timesheet"}]) + + @patch.object(TempoServer, "get") + def test_get_timesheet(self, mock_get): + """Test get_timesheet method.""" + mock_get.return_value = {"id": 1, "name": "Test Timesheet"} + result = self.tempo.timesheets.get_timesheet(1) + mock_get.assert_called_once_with("1", **{}) + self.assertEqual(result, {"id": 1, "name": "Test Timesheet"}) + + +class TestTempoServerServlet(unittest.TestCase): + """Test cases for TempoServer servlet module.""" + + def setUp(self): + """Set up test fixtures.""" + self.tempo = TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_worklogs(self, mock_get): + """Test get_worklogs method.""" + mock_get.return_value = [{"id": 1, "timeSpentSeconds": 3600}] + result = self.tempo.servlet.get_worklogs() + mock_get.assert_called_once_with("", **{}) + self.assertEqual(result, [{"id": 1, "timeSpentSeconds": 3600}]) + + @patch.object(TempoServer, "get") + def test_get_worklog(self, mock_get): + """Test get_worklog method.""" + mock_get.return_value = {"id": 1, "timeSpentSeconds": 3600} + result = self.tempo.servlet.get_worklog(1) + mock_get.assert_called_once_with("1", **{}) + self.assertEqual(result, {"id": 1, "timeSpentSeconds": 3600}) + + +class TestTempoServerEvents(unittest.TestCase): + """Test cases for TempoServer events module.""" + + def setUp(self): + """Set up test fixtures.""" + self.tempo = TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_events(self, mock_get): + """Test get_events method.""" + mock_get.return_value = [{"id": 1, "type": "worklog_created"}] + result = self.tempo.events.get_events() + mock_get.assert_called_once_with("", **{}) + self.assertEqual(result, [{"id": 1, "type": "worklog_created"}]) + + @patch.object(TempoServer, "get") + def test_get_event(self, mock_get): + """Test get_event method.""" + mock_get.return_value = {"id": 1, "type": "worklog_created"} + result = self.tempo.events.get_event(1) + mock_get.assert_called_once_with("1", **{}) + self.assertEqual(result, {"id": 1, "type": "worklog_created"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tempo_server_pytest.py b/tests/test_tempo_server_pytest.py new file mode 100644 index 000000000..a861b6361 --- /dev/null +++ b/tests/test_tempo_server_pytest.py @@ -0,0 +1,234 @@ +# coding=utf-8 +""" +Test cases for Tempo Server API clients using pytest. +""" + +import pytest +from unittest.mock import patch + +from atlassian.tempo import TempoServer + + +@pytest.fixture +def tempo_server(): + """Fixture for TempoServer client.""" + return TempoServer(url="https://test.atlassian.net", token="test-token", cloud=False) + + +class TestTempoServer: + """Test cases for base TempoServer client.""" + + def test_init_defaults(self): + """Test TempoServer client initialization with default values.""" + tempo = TempoServer(url="https://test.atlassian.net", token="test-token") + assert tempo.api_version == "1" + assert tempo.api_root == "rest/tempo-core/1" + + def test_init_custom_values(self): + """Test TempoServer client initialization with custom values.""" + tempo = TempoServer( + url="https://test.atlassian.net", token="test-token", api_version="2", api_root="custom/api/root" + ) + assert tempo.api_version == "2" + assert tempo.api_root == "custom/api/root" + + def test_specialized_modules_exist(self, tempo_server): + """Test that specialized modules are properly initialized.""" + assert tempo_server.accounts is not None + assert tempo_server.teams is not None + assert tempo_server.planner is not None + assert tempo_server.budgets is not None + assert tempo_server.timesheets is not None + assert tempo_server.servlet is not None + assert tempo_server.events is not None + + @patch.object(TempoServer, "get") + def test_get_health(self, mock_get, tempo_server): + """Test get_health method.""" + mock_get.return_value = {"status": "healthy"} + result = tempo_server.get_health() + mock_get.assert_called_once_with("health", **{}) + assert result == {"status": "healthy"} + + @patch.object(TempoServer, "get") + def test_get_metadata(self, mock_get, tempo_server): + """Test get_metadata method.""" + mock_get.return_value = {"version": "1.0.0"} + result = tempo_server.get_metadata() + mock_get.assert_called_once_with("metadata", **{}) + assert result == {"version": "1.0.0"} + + +class TestTempoServerAccounts: + """Test cases for TempoServer accounts module.""" + + @pytest.fixture + def tempo_server(self): + """Fixture for TempoServer client.""" + return TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_accounts(self, mock_get, tempo_server): + """Test get_accounts method.""" + mock_get.return_value = [{"id": 1, "name": "Test Account"}] + result = tempo_server.accounts.get_accounts() + mock_get.assert_called_once_with("", **{}) + assert result == [{"id": 1, "name": "Test Account"}] + + @patch.object(TempoServer, "get") + def test_get_account(self, mock_get, tempo_server): + """Test get_account method.""" + mock_get.return_value = {"id": 1, "name": "Test Account"} + result = tempo_server.accounts.get_account(1) + mock_get.assert_called_once_with("1", **{}) + assert result == {"id": 1, "name": "Test Account"} + + +class TestTempoServerTeams: + """Test cases for TempoServer teams module.""" + + @pytest.fixture + def tempo_server(self): + """Fixture for TempoServer client.""" + return TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_teams(self, mock_get, tempo_server): + """Test get_teams method.""" + mock_get.return_value = [{"id": 1, "name": "Test Team"}] + result = tempo_server.teams.get_teams() + mock_get.assert_called_once_with("", **{}) + assert result == [{"id": 1, "name": "Test Team"}] + + @patch.object(TempoServer, "get") + def test_get_team(self, mock_get, tempo_server): + """Test get_team method.""" + mock_get.return_value = {"id": 1, "name": "Test Team"} + result = tempo_server.teams.get_team(1) + mock_get.assert_called_once_with("1", **{}) + assert result == {"id": 1, "name": "Test Team"} + + +class TestTempoServerPlanner: + """Test cases for TempoServer planner module.""" + + @pytest.fixture + def tempo_server(self): + """Fixture for TempoServer client.""" + return TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_plans(self, mock_get, tempo_server): + """Test get_plans method.""" + mock_get.return_value = [{"id": 1, "name": "Test Plan"}] + result = tempo_server.planner.get_plans() + mock_get.assert_called_once_with("", **{}) + assert result == [{"id": 1, "name": "Test Plan"}] + + @patch.object(TempoServer, "get") + def test_get_plan(self, mock_get, tempo_server): + """Test get_plan method.""" + mock_get.return_value = {"id": 1, "name": "Test Plan"} + result = tempo_server.planner.get_plan(1) + mock_get.assert_called_once_with("1", **{}) + assert result == {"id": 1, "name": "Test Plan"} + + +class TestTempoServerBudgets: + """Test cases for TempoServer budgets module.""" + + @pytest.fixture + def tempo_server(self): + """Fixture for TempoServer client.""" + return TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_budgets(self, mock_get, tempo_server): + """Test get_budgets method.""" + mock_get.return_value = [{"id": 1, "name": "Test Budget"}] + result = tempo_server.budgets.get_budgets() + mock_get.assert_called_once_with("", **{}) + assert result == [{"id": 1, "name": "Test Budget"}] + + @patch.object(TempoServer, "get") + def test_get_budget(self, mock_get, tempo_server): + """Test get_budget method.""" + mock_get.return_value = {"id": 1, "name": "Test Budget"} + result = tempo_server.budgets.get_budget(1) + mock_get.assert_called_once_with("1", **{}) + assert result == {"id": 1, "name": "Test Budget"} + + +class TestTempoServerTimesheets: + """Test cases for TempoServer timesheets module.""" + + @pytest.fixture + def tempo_server(self): + """Fixture for TempoServer client.""" + return TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_timesheets(self, mock_get, tempo_server): + """Test get_timesheets method.""" + mock_get.return_value = [{"id": 1, "name": "Test Timesheet"}] + result = tempo_server.timesheets.get_timesheets() + mock_get.assert_called_once_with("", **{}) + assert result == [{"id": 1, "name": "Test Timesheet"}] + + @patch.object(TempoServer, "get") + def test_get_timesheet(self, mock_get, tempo_server): + """Test get_timesheet method.""" + mock_get.return_value = {"id": 1, "name": "Test Timesheet"} + result = tempo_server.timesheets.get_timesheet(1) + mock_get.assert_called_once_with("1", **{}) + assert result == {"id": 1, "name": "Test Timesheet"} + + +class TestTempoServerServlet: + """Test cases for TempoServer servlet module.""" + + @pytest.fixture + def tempo_server(self): + """Fixture for TempoServer client.""" + return TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_worklogs(self, mock_get, tempo_server): + """Test get_worklogs method.""" + mock_get.return_value = [{"id": 1, "timeSpentSeconds": 3600}] + result = tempo_server.servlet.get_worklogs() + mock_get.assert_called_once_with("", **{}) + assert result == [{"id": 1, "timeSpentSeconds": 3600}] + + @patch.object(TempoServer, "get") + def test_get_worklog(self, mock_get, tempo_server): + """Test get_worklog method.""" + mock_get.return_value = {"id": 1, "timeSpentSeconds": 3600} + result = tempo_server.servlet.get_worklog(1) + mock_get.assert_called_once_with("1", **{}) + assert result == {"id": 1, "timeSpentSeconds": 3600} + + +class TestTempoServerEvents: + """Test cases for TempoServer events module.""" + + @pytest.fixture + def tempo_server(self): + """Fixture for TempoServer client.""" + return TempoServer(url="https://test.atlassian.net", token="test-token") + + @patch.object(TempoServer, "get") + def test_get_events(self, mock_get, tempo_server): + """Test get_events method.""" + mock_get.return_value = [{"id": 1, "type": "worklog_created"}] + result = tempo_server.events.get_events() + mock_get.assert_called_once_with("", **{}) + assert result == [{"id": 1, "type": "worklog_created"}] + + @patch.object(TempoServer, "get") + def test_get_event(self, mock_get, tempo_server): + """Test get_event method.""" + mock_get.return_value = {"id": 1, "type": "worklog_created"} + result = tempo_server.events.get_event(1) + mock_get.assert_called_once_with("1", **{}) + assert result == {"id": 1, "type": "worklog_created"}