From d13dc93eee88013d660c533bb7da5245ecd61da2 Mon Sep 17 00:00:00 2001 From: Gonchik Tsymzhitov Date: Sun, 11 May 2025 22:33:50 +0300 Subject: [PATCH 1/4] Confluence: Prepare dedicated module --- atlassian/bamboo.py | 1 + atlassian/bitbucket/base.py | 2 +- atlassian/{confluence.py => confluence/__init___.py} | 5 ++--- 3 files changed, 4 insertions(+), 4 deletions(-) rename atlassian/{confluence.py => confluence/__init___.py} (99%) diff --git a/atlassian/bamboo.py b/atlassian/bamboo.py index 66b60bbaf..a9fa51441 100755 --- a/atlassian/bamboo.py +++ b/atlassian/bamboo.py @@ -2,6 +2,7 @@ import logging from requests.exceptions import HTTPError + from .rest_client import AtlassianRestAPI log = logging.getLogger(__name__) diff --git a/atlassian/bitbucket/base.py b/atlassian/bitbucket/base.py index 4da72541d..750624076 100644 --- a/atlassian/bitbucket/base.py +++ b/atlassian/bitbucket/base.py @@ -3,9 +3,9 @@ import copy import re import sys - from datetime import datetime from pprint import PrettyPrinter + from ..rest_client import AtlassianRestAPI RE_TIMEZONE = re.compile(r"(\d{2}):(\d{2})$") diff --git a/atlassian/confluence.py b/atlassian/confluence/__init___.py similarity index 99% rename from atlassian/confluence.py rename to atlassian/confluence/__init___.py index e2f856b3e..d72da9e02 100644 --- a/atlassian/confluence.py +++ b/atlassian/confluence/__init___.py @@ -13,8 +13,7 @@ from requests import HTTPError from atlassian import utils - -from .errors import ( +from atlassian.errors import ( ApiConflictError, ApiError, ApiNotAcceptable, @@ -22,7 +21,7 @@ ApiPermissionError, ApiValueError, ) -from .rest_client import AtlassianRestAPI +from atlassian.rest_client import AtlassianRestAPI log = logging.getLogger(__name__) From c41e576d3403a973261440f7b7ee56eae5d587ba Mon Sep 17 00:00:00 2001 From: Gonchik Tsymzhitov Date: Sun, 11 May 2025 22:45:38 +0300 Subject: [PATCH 2/4] Fix tests --- atlassian/bitbucket/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/atlassian/bitbucket/__init__.py b/atlassian/bitbucket/__init__.py index b200f7c76..fa1a24cc0 100644 --- a/atlassian/bitbucket/__init__.py +++ b/atlassian/bitbucket/__init__.py @@ -162,7 +162,6 @@ def get_users_info(self, user_filter=None, start=0, limit=25): params["filter"] = user_filter return self._get_paged(url, params=params) - def get_current_license(self): """ Retrieves details about the current license, as well as the current status of the system with From 9a3305bf9b0bcb588478ede812ff20117eac32f3 Mon Sep 17 00:00:00 2001 From: Gonchik Tsymzhitov Date: Sun, 24 Aug 2025 23:42:54 +0300 Subject: [PATCH 3/4] Tempo API wrap from API specs #446 --- atlassian/__init__.py | 3 + atlassian/tempo/__init__.py | 14 + atlassian/tempo/cloud/__init__.py | 232 ++++++++++ atlassian/tempo/cloud/base.py | 38 ++ atlassian/tempo/server/__init__.py | 78 ++++ atlassian/tempo/server/accounts.py | 38 ++ atlassian/tempo/server/base.py | 50 +++ atlassian/tempo/server/budgets.py | 42 ++ atlassian/tempo/server/events.py | 52 +++ atlassian/tempo/server/planner.py | 42 ++ atlassian/tempo/server/servlet.py | 46 ++ atlassian/tempo/server/teams.py | 50 +++ atlassian/tempo/server/timesheets.py | 54 +++ docs/tempo.rst | 620 +++++++++++++++++++++++++++ pytest.ini | 21 + tests/conftest.py | 51 +++ tests/test_tempo_cloud.py | 458 ++++++++++++++++++++ tests/test_tempo_cloud_pytest.py | 195 +++++++++ tests/test_tempo_server.py | 229 ++++++++++ tests/test_tempo_server_pytest.py | 234 ++++++++++ 20 files changed, 2547 insertions(+) create mode 100644 atlassian/tempo/__init__.py create mode 100644 atlassian/tempo/cloud/__init__.py create mode 100644 atlassian/tempo/cloud/base.py create mode 100644 atlassian/tempo/server/__init__.py create mode 100644 atlassian/tempo/server/accounts.py create mode 100644 atlassian/tempo/server/base.py create mode 100644 atlassian/tempo/server/budgets.py create mode 100644 atlassian/tempo/server/events.py create mode 100644 atlassian/tempo/server/planner.py create mode 100644 atlassian/tempo/server/servlet.py create mode 100644 atlassian/tempo/server/teams.py create mode 100644 atlassian/tempo/server/timesheets.py create mode 100644 docs/tempo.rst create mode 100644 pytest.ini create mode 100644 tests/conftest.py create mode 100644 tests/test_tempo_cloud.py create mode 100644 tests/test_tempo_cloud_pytest.py create mode 100644 tests/test_tempo_server.py create mode 100644 tests/test_tempo_server_pytest.py 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/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"} From e00cf8e1c34e35c06c4f79faaff6a7567380df67 Mon Sep 17 00:00:00 2001 From: Gonchik Tsymzhitov Date: Mon, 25 Aug 2025 09:50:36 +0300 Subject: [PATCH 4/4] Remove incorrect init file --- atlassian/confluence/__init___.py | 3925 ----------------------------- 1 file changed, 3925 deletions(-) delete mode 100644 atlassian/confluence/__init___.py diff --git a/atlassian/confluence/__init___.py b/atlassian/confluence/__init___.py deleted file mode 100644 index d72da9e02..000000000 --- a/atlassian/confluence/__init___.py +++ /dev/null @@ -1,3925 +0,0 @@ -# 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)