diff --git a/jira/client.py b/jira/client.py index 1c8534869..892c12416 100644 --- a/jira/client.py +++ b/jira/client.py @@ -5,6 +5,7 @@ will construct a JIRA object as described below. Full API documentation can be found at: https://jira.readthedocs.io/en/latest/. """ + from __future__ import annotations import calendar @@ -49,7 +50,7 @@ from requests_toolbelt import MultipartEncoder from jira import __version__ -from jira.exceptions import JIRAError +from jira.exceptions import JIRAError, NotJIRAInstanceError from jira.resilientsession import PrepareRequestForRetry, ResilientSession from jira.resources import ( AgileResource, @@ -60,6 +61,9 @@ Customer, CustomFieldOption, Dashboard, + DashboardGadget, + DashboardItemProperty, + DashboardItemPropertyKey, Field, Filter, Group, @@ -92,7 +96,7 @@ WorkflowScheme, Worklog, ) -from jira.utils import json_loads, threaded_requests +from jira.utils import json_loads, remove_empty_attributes, threaded_requests try: from requests_jwt import JWTAuth @@ -104,6 +108,82 @@ LOG.addHandler(_logging.NullHandler()) +def cloud_api(client_method: Callable) -> Callable: + """A convenience decorator to check if the Jira instance is cloud. + + Checks if the client instance is talking to Cloud Jira. If it is, return + the result of the called client method. If not, return None and log a + warning. + + Args: + client_method: The method that is being called by the client. + + Returns: + Either the result of the wrapped function or None. + + Raises: + JIRAError: In the case the error is not an HTTP error with a status code. + NotJIRAInstanceError: In the case that the first argument to this method + is not a `client.JIRA` instance. + """ + wraps(client_method) + + def check_if_cloud(*args, **kwargs): + # The first argument of any class instance is a `self` + # reference. Avoiding magic numbers here. + instance = next(arg for arg in args) + if not isinstance(instance, JIRA): + raise NotJIRAInstanceError(instance) + + if instance._is_cloud: + return client_method(*args, **kwargs) + + instance.log.warning( + "This functionality is not available on Jira Data Center (Server) version." + ) + return None + + return check_if_cloud + + +def experimental_atlassian_api(client_method: Callable) -> Callable: + """A convenience decorator to inform if a client method is experimental. + + Indicates the path covered by the client method is experimental. If the path + disappears or the method becomes disallowed, this logs an error and returns + None. If another kind of exception is raised, this reraises. + + Raises: + JIRAError: In the case the error is not an HTTP error with a status code. + NotJIRAInstanceError: In the case that the first argument to this method is + is not a `client.JIRA` instance. + + Returns: + Either the result of the wrapped function or None. + """ + wraps(client_method) + + def is_experimental(*args, **kwargs): + instance = next(arg for arg in args) + if not isinstance(instance, JIRA): + raise NotJIRAInstanceError(instance) + + try: + return client_method(*args, **kwargs) + except JIRAError as e: + response = getattr(e, "response", None) + if response is not None and response.status_code in [405, 404]: + instance.log.warning( + f"Functionality at path {response.url} is/was experimental. " + f"Status Code: {response.status_code}" + ) + return None + else: + raise + + return is_experimental + + def translate_resource_args(func: Callable): """Decorator that converts Issue and Project resources to their keys when used as arguments. @@ -1180,7 +1260,7 @@ def dashboards( maxResults (int): maximum number of dashboards to return. If maxResults set to False, it will try to get all items in batches. (Default: ``20``) Returns: - ResultList + ResultList[Dashboard] """ params = {} if filter is not None: @@ -1203,7 +1283,253 @@ def dashboard(self, id: str) -> Dashboard: Returns: Dashboard """ - return self._find_for_resource(Dashboard, id) + dashboard = self._find_for_resource(Dashboard, id) + dashboard.gadgets.extend(self.dashboard_gadgets(id) or []) + return dashboard + + @cloud_api + @experimental_atlassian_api + def create_dashboard( + self, + name: str, + description: str | None = None, + edit_permissions: list[dict] | list | None = None, + share_permissions: list[dict] | list | None = None, + ) -> Dashboard: + """Create a new dashboard and return a dashboard resource for it. + + Args: + name (str): Name of the new dashboard `required`. + description (Optional[str]): Useful human-readable description of the new dashboard. + edit_permissions (list | list[dict]): A list of permissions dicts `required` + though can be an empty list. + share_permissions (list | list[dict]): A list of permissions dicts `required` + though can be an empty list. + + Returns: + Dashboard + """ + data: dict[str, Any] = remove_empty_attributes( + { + "name": name, + "editPermissions": edit_permissions or [], + "sharePermissions": share_permissions or [], + "description": description, + } + ) + url = self._get_url("dashboard") + r = self._session.post(url, data=json.dumps(data)) + + raw_dashboard_json: dict[str, Any] = json_loads(r) + return Dashboard(self._options, self._session, raw=raw_dashboard_json) + + @cloud_api + @experimental_atlassian_api + def copy_dashboard( + self, + id: str, + name: str, + description: str | None = None, + edit_permissions: list[dict] | list | None = None, + share_permissions: list[dict] | list | None = None, + ) -> Dashboard: + """Copy an existing dashboard. + + Args: + id (str): The ``id`` of the ``Dashboard`` to copy. + name (str): Name of the new dashboard `required`. + description (Optional[str]): Useful human-readable description of the new dashboard. + edit_permissions (list | list[dict]): A list of permissions dicts `required` + though can be an empty list. + share_permissions (list | list[dict]): A list of permissions dicts `required` + though can be an empty list. + + Returns: + Dashboard + """ + data: dict[str, Any] = remove_empty_attributes( + { + "name": name, + "editPermissions": edit_permissions or [], + "sharePermissions": share_permissions or [], + "description": description, + } + ) + url = self._get_url("dashboard") + url = f"{url}/{id}/copy" + r = self._session.post(url, json=data) + + raw_dashboard_json: dict[str, Any] = json_loads(r) + return Dashboard(self._options, self._session, raw=raw_dashboard_json) + + @cloud_api + @experimental_atlassian_api + def update_dashboard_automatic_refresh_minutes( + self, id: str, minutes: int + ) -> Response: + """Update the automatic refresh interval of a dashboard. + + Args: + id (str): The ``id`` of the ``Dashboard`` to copy. + minutes (int): The frequency of the dashboard automatic refresh in minutes. + + Returns: + Response + """ + # The payload expects milliseconds, we are doing a conversion + # here as a convenience. Additionally, if the value is `0` then we are setting + # to `None` which will serialize to `null` in `json` which is what is + # expected if the user wants to turn it off. + + value = minutes * 60000 if minutes else None + data = {"automaticRefreshMs": value} + + url = self._get_internal_url(f"dashboards/{id}/automatic-refresh-ms") + return self._session.put(url, json=data) + + def dashboard_item_property_keys( + self, dashboard_id: str, item_id: str + ) -> ResultList[DashboardItemPropertyKey]: + """Return a ResultList of a Dashboard gadget's property keys. + + Args: + dashboard_id (str): ID of dashboard. + item_id (str): ID of dashboard item (``DashboardGadget``). + + Returns: + ResultList[DashboardItemPropertyKey] + """ + return self._fetch_pages( + DashboardItemPropertyKey, + "keys", + f"dashboard/{dashboard_id}/items/{item_id}/properties", + ) + + def dashboard_item_property( + self, dashboard_id: str, item_id: str, property_key: str + ) -> DashboardItemProperty: + """Get the item property for a specific dashboard item (DashboardGadget). + + Args: + dashboard_id (str): of the dashboard. + item_id (str): ID of the item (``DashboardGadget``) on the dashboard. + property_key (str): KEY of the gadget property. + + Returns: + DashboardItemProperty + """ + dashboard_item_property = self._find_for_resource( + DashboardItemProperty, (dashboard_id, item_id, property_key) + ) + return dashboard_item_property + + def set_dashboard_item_property( + self, dashboard_id: str, item_id: str, property_key: str, value: dict[str, Any] + ) -> DashboardItemProperty: + """Set a dashboard item property. + + Args: + dashboard_id (str): Dashboard id. + item_id (str): ID of dashboard item (``DashboardGadget``) to add property_key to. + property_key (str): The key of the property to set. + value (dict[str, Any]): The dictionary containing the value of the property key. + + Returns: + DashboardItemProperty + """ + url = self._get_url( + f"dashboard/{dashboard_id}/items/{item_id}/properties/{property_key}" + ) + r = self._session.put(url, json=value) + + if not r.ok: + raise JIRAError(status_code=r.status_code, request=r) + return self.dashboard_item_property(dashboard_id, item_id, property_key) + + @cloud_api + @experimental_atlassian_api + def dashboard_gadgets(self, dashboard_id: str) -> list[DashboardGadget]: + """Return a list of DashboardGadget resources for the specified dashboard. + + Args: + dashboard_id (str): the ``dashboard_id`` of the dashboard to get gadgets for + + Returns: + list[DashboardGadget] + """ + gadgets: list[DashboardGadget] = [] + gadgets = self._fetch_pages( + DashboardGadget, "gadgets", f"dashboard/{dashboard_id}/gadget" + ) + for gadget in gadgets: + for dashboard_item_key in self.dashboard_item_property_keys( + dashboard_id, gadget.id + ): + gadget.item_properties.append( + self.dashboard_item_property( + dashboard_id, gadget.id, dashboard_item_key.key + ) + ) + + return gadgets + + @cloud_api + @experimental_atlassian_api + def all_dashboard_gadgets(self) -> ResultList[DashboardGadget]: + """Return a ResultList of available DashboardGadget resources and a ``total`` count. + + Returns: + ResultList[DashboardGadget] + """ + return self._fetch_pages(DashboardGadget, "gadgets", "dashboard/gadgets") + + @cloud_api + @experimental_atlassian_api + def add_gadget_to_dashboard( + self, + dashboard_id: str | Dashboard, + color: str | None = None, + ignore_uri_and_module_key_validation: bool | None = None, + module_key: str | None = None, + position: dict[str, int] | None = None, + title: str | None = None, + uri: str | None = None, + ) -> DashboardGadget: + """Add a gadget to a dashboard and return a ``DashboardGadget`` resource. + + Args: + dashboard_id (str): The ``dashboard_id`` of the dashboard to add the gadget to `required`. + color (str): The color of the gadget, should be one of: blue, red, yellow, + green, cyan, purple, gray, or white. + ignore_uri_and_module_key_validation (bool): Whether to ignore the + validation of the module key and URI. For example, when a gadget is created + that is part of an application that is not installed. + module_key (str): The module to use in the gadget. Mutually exclusive with + `uri`. + position (dict[str, int]): A dictionary containing position information like - + `{"column": 0, "row", 1}`. + title (str): The title of the gadget. + uri (str): The uri to the module to use in the gadget. Mutually exclusive + with `module_key`. + + Returns: + DashboardGadget + """ + data = remove_empty_attributes( + { + "color": color, + "ignoreUriAndModuleKeyValidation": ignore_uri_and_module_key_validation, + "module_key": module_key, + "position": position, + "title": title, + "uri": uri, + } + ) + url = self._get_url(f"dashboard/{dashboard_id}/gadget") + r = self._session.post(url, json=data) + + raw_gadget_json: dict[str, Any] = json_loads(r) + return DashboardGadget(self._options, self._session, raw=raw_gadget_json) # Fields @@ -1392,11 +1718,13 @@ def group_members(self, group: str) -> OrderedDict: hasId = user.get("id") is not None and user.get("id") != "" hasName = user.get("name") is not None and user.get("name") != "" result[ - user["id"] - if hasId - else user.get("name") - if hasName - else user.get("accountId") + ( + user["id"] + if hasId + else user.get("name") + if hasName + else user.get("accountId") + ) ] = { "name": user.get("name"), "id": user.get("id"), @@ -2377,15 +2705,15 @@ def worklog(self, issue: str | int, id: str) -> Worklog: def add_worklog( self, issue: str | int, - timeSpent: (str | None) = None, - timeSpentSeconds: (str | None) = None, - adjustEstimate: (str | None) = None, - newEstimate: (str | None) = None, - reduceBy: (str | None) = None, - comment: (str | None) = None, - started: (datetime.datetime | None) = None, - user: (str | None) = None, - visibility: (dict[str, Any] | None) = None, + timeSpent: str | None = None, + timeSpentSeconds: str | None = None, + adjustEstimate: str | None = None, + newEstimate: str | None = None, + reduceBy: str | None = None, + comment: str | None = None, + started: datetime.datetime | None = None, + user: str | None = None, + visibility: dict[str, Any] | None = None, ) -> Worklog: """Add a new worklog entry on an issue and return a Resource for it. @@ -3875,6 +4203,24 @@ def _set_avatar(self, params, url, avatar): data = {"id": avatar} return self._session.put(url, params=params, data=json.dumps(data)) + def _get_internal_url(self, path: str, base: str = JIRA_BASE_URL) -> str: + """Returns the full internal api url based on Jira base url and the path provided. + + Using the API version specified during the __init__. + + Args: + path (str): The subpath desired. + base (Optional[str]): The base url which should be prepended to the path + + Returns: + str: Fully qualified URL + """ + options = self._options.copy() + options.update( + {"path": path, "rest_api_version": "latest", "rest_path": "internal"} + ) + return base.format(**options) + def _get_url(self, path: str, base: str = JIRA_BASE_URL) -> str: """Returns the full url based on Jira base url and the path provided. @@ -3941,7 +4287,7 @@ def _get_json( def _find_for_resource( self, resource_cls: Any, - ids: tuple[str, str] | tuple[str | int, str] | int | str, + ids: tuple[str, ...] | tuple[str | int, str] | int | str, expand=None, ) -> Any: """Uses the find method of the provided Resource class. diff --git a/jira/exceptions.py b/jira/exceptions.py index 027ce14ba..0047133e5 100644 --- a/jira/exceptions.py +++ b/jira/exceptions.py @@ -2,6 +2,7 @@ import os import tempfile +from typing import Any from requests import Response @@ -69,3 +70,14 @@ def __str__(self) -> str: t += f"\n\t{details}" return t + + +class NotJIRAInstanceError(Exception): + """Raised in the case an object is not a JIRA instance.""" + + def __init__(self, instance: Any): + msg = ( + "The first argument of this function must be an instance of type " + f"JIRA. Instance Type: {instance.__class__.__name__}" + ) + super().__init__(msg) diff --git a/jira/resources.py b/jira/resources.py index 57ec31bbc..0f48aa882 100644 --- a/jira/resources.py +++ b/jira/resources.py @@ -3,6 +3,7 @@ This module implements the Resource classes that translate JSON from Jira REST resources into usable objects. """ + from __future__ import annotations import json @@ -15,7 +16,7 @@ from requests.structures import CaseInsensitiveDict from jira.resilientsession import ResilientSession, parse_errors -from jira.utils import json_loads, threaded_requests +from jira.utils import json_loads, remove_empty_attributes, threaded_requests if TYPE_CHECKING: from jira.client import JIRA @@ -37,7 +38,10 @@ class AnyLike: "Attachment", "Component", "Dashboard", + "DashboardItemProperty", + "DashboardItemPropertyKey", "Filter", + "DashboardGadget", "Votes", "PermissionScheme", "Watchers", @@ -239,7 +243,7 @@ def __eq__(self, other: Any) -> bool: def find( self, - id: tuple[str, str] | int | str, + id: tuple[str, ...] | int | str, params: dict[str, str] | None = None, ): """Finds a resource based on the input parameters. @@ -552,8 +556,157 @@ def __init__( Resource.__init__(self, "dashboard/{0}", options, session) if raw: self._parse_raw(raw) + self.gadgets: list[DashboardGadget] = [] + self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + + +class DashboardItemPropertyKey(Resource): + """A jira dashboard item property key.""" + + def __init__( + self, + options: dict[str, str], + session: ResilientSession, + raw: dict[str, Any] = None, + ): + Resource.__init__(self, "dashboard/{0}/items/{1}/properties", options, session) + if raw: + self._parse_raw(raw) + self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + + +class DashboardItemProperty(Resource): + """A jira dashboard item.""" + + def __init__( + self, + options: dict[str, str], + session: ResilientSession, + raw: dict[str, Any] = None, + ): + Resource.__init__( + self, "dashboard/{0}/items/{1}/properties/{2}", options, session + ) + if raw: + self._parse_raw(raw) + self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + + def update( # type: ignore[override] # incompatible supertype ignored + self, dashboard_id: str, item_id: str, value: dict[str, Any] + ) -> DashboardItemProperty: + """Update this resource on the server. + + Keyword arguments are marshalled into a dict before being sent. If this resource doesn't support ``PUT``, a :py:exc:`.JIRAError` + will be raised; subclasses that specialize this method will only raise errors in case of user error. + + Args: + dashboard_id (str): The ``id`` if the dashboard. + item_id (str): The id of the dashboard item (``DashboardGadget``) to target. + value (dict[str, Any]): The value of the targeted property key. + + Returns: + DashboardItemProperty + """ + options = self._options.copy() + options[ + "path" + ] = f"dashboard/{dashboard_id}/items/{item_id}/properties/{self.key}" + self.raw["value"].update(value) + self._session.put(self.JIRA_BASE_URL.format(**options), self.raw["value"]) + + return DashboardItemProperty(self._options, self._session, raw=self.raw) + + def delete(self, dashboard_id: str, item_id: str) -> Response: # type: ignore[override] # incompatible supertype ignored + """Delete dashboard item property. + + Args: + dashboard_id (str): The ``id`` of the dashboard. + item_id (str): The ``id`` of the dashboard item (``DashboardGadget``). + + + Returns: + Response + """ + options = self._options.copy() + options[ + "path" + ] = f"dashboard/{dashboard_id}/items/{item_id}/properties/{self.key}" + + return self._session.delete(self.JIRA_BASE_URL.format(**options)) + + +class DashboardGadget(Resource): + """A jira dashboard gadget.""" + + def __init__( + self, + options: dict[str, str], + session: ResilientSession, + raw: dict[str, Any] = None, + ): + Resource.__init__(self, "dashboard/{0}/gadget/{1}", options, session) + if raw: + self._parse_raw(raw) + self.item_properties: list[DashboardItemProperty] = [] self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) + def update( # type: ignore[override] # incompatible supertype ignored + self, + dashboard_id: str, + color: str | None = None, + position: dict[str, Any] | None = None, + title: str | None = None, + ) -> DashboardGadget: + """Update this resource on the server. + + Keyword arguments are marshalled into a dict before being sent. If this resource doesn't support ``PUT``, a :py:exc:`.JIRAError` + will be raised; subclasses that specialize this method will only raise errors in case of user error. + + Args: + dashboard_id (str): The ``id`` of the dashboard to add the gadget to `required`. + color (str): The color of the gadget, should be one of: blue, red, yellow, + green, cyan, purple, gray, or white. + ignore_uri_and_module_key_validation (bool): Whether to ignore the + validation of the module key and URI. For example, when a gadget is created + that is part of an application that is not installed. + position (dict[str, int]): A dictionary containing position information like - + `{"column": 0, "row", 1}`. + title (str): The title of the gadget. + + Returns: + ``DashboardGadget`` + """ + data = remove_empty_attributes( + {"color": color, "position": position, "title": title} + ) + options = self._options.copy() + options["path"] = f"dashboard/{dashboard_id}/gadget/{self.id}" + + self._session.put(self.JIRA_BASE_URL.format(**options), json=data) + options["path"] = f"dashboard/{dashboard_id}/gadget" + + return next( + DashboardGadget(self._options, self._session, raw=gadget) + for gadget in self._session.get( + self.JIRA_BASE_URL.format(**options) + ).json()["gadgets"] + if gadget["id"] == self.id + ) + + def delete(self, dashboard_id: str) -> Response: # type: ignore[override] # incompatible supertype ignored + """Delete gadget from dashboard. + + Args: + dashboard_id (str): The ``id`` of the dashboard. + + Returns: + Response + """ + options = self._options.copy() + options["path"] = f"dashboard/{dashboard_id}/gadget/{self.id}" + + return self._session.delete(self.JIRA_BASE_URL.format(**options)) + class Field(Resource): """An issue field. @@ -1492,6 +1645,9 @@ def dict2resource( r"component/[^/]+$": Component, r"customFieldOption/[^/]+$": CustomFieldOption, r"dashboard/[^/]+$": Dashboard, + r"dashboard/[^/]+/items/[^/]+/properties+$": DashboardItemPropertyKey, + r"dashboard/[^/]+/items/[^/]+/properties/[^/]+$": DashboardItemProperty, + r"dashboard/[^/]+/gadget/[^/]+$": DashboardGadget, r"filter/[^/]$": Filter, r"issue/[^/]+$": Issue, r"issue/[^/]+/comment/[^/]+$": Comment, diff --git a/jira/utils/__init__.py b/jira/utils/__init__.py index c8945f3e4..f512ed44a 100644 --- a/jira/utils/__init__.py +++ b/jira/utils/__init__.py @@ -1,4 +1,5 @@ """Jira utils used internally.""" + from __future__ import annotations import threading @@ -79,3 +80,15 @@ def json_loads(resp: Response | None) -> Any: if not resp.text: return {} raise + + +def remove_empty_attributes(data: dict[str, Any]) -> dict[str, Any]: + """A convenience function to remove key/value pairs with `None` for a value. + + Args: + data: A dictionary. + + Returns: + Dict[str, Any]: A dictionary with no `None` key/value pairs. + """ + return {key: val for key, val in data.items() if val is not None} diff --git a/tests/conftest.py b/tests/conftest.py index 140cb07d9..38f08ef0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,10 @@ allow_on_cloud = pytest.mark.allow_on_cloud +only_run_on_cloud = pytest.mark.skipif( + os.environ.get("CI_JIRA_TYPE", "Server").upper() != "CLOUD", + reason="Functionality only available on Jira Cloud", +) broken_test = pytest.mark.xfail diff --git a/tests/resources/test_dashboard.py b/tests/resources/test_dashboard.py index e2327bfd5..a282f3538 100644 --- a/tests/resources/test_dashboard.py +++ b/tests/resources/test_dashboard.py @@ -1,9 +1,43 @@ from __future__ import annotations -from tests.conftest import JiraTestCase, broken_test +from unittest import mock + +import pytest + +from jira.exceptions import JIRAError +from jira.resources import ( + Dashboard, + DashboardGadget, + DashboardItemProperty, + DashboardItemPropertyKey, +) +from tests.conftest import ( + JiraTestCase, + allow_on_cloud, + broken_test, + only_run_on_cloud, + rndstr, +) class DashboardTests(JiraTestCase): + def setUp(self): + super().setUp() + self.dashboards_to_delete = [] + self.gadget_title = "Filter Results" + self.dashboard_item_expected_key = "config" + self.dashboard_item_column_names = "issuetype|issuekey|summary|priority|status" + self.dashboard_item_num = 5 + self.dashboard_item_refresh = 15 + self.filter = self.jira.create_filter( + rndstr(), "description", f"project={self.project_b}", True + ) + + def tearDown(self): + for dashboard in self.dashboards_to_delete: + dashboard.delete() + super().tearDown() + def test_dashboards(self): dashboards = self.jira.dashboards() self.assertGreaterEqual(len(dashboards), 1) @@ -29,3 +63,334 @@ def test_dashboard(self): dashboard = self.jira.dashboard(expected_ds.id) self.assertEqual(dashboard.id, expected_ds.id) self.assertEqual(dashboard.name, expected_ds.name) + + @only_run_on_cloud + @allow_on_cloud + def test_create_dashboard(self): + name = rndstr() + description = rndstr() + share_permissions = [{"type": "authenticated"}] + + dashboard = self.jira.create_dashboard( + name=name, description=description, share_permissions=share_permissions + ) + self.assertIsInstance(dashboard, Dashboard) + self.dashboards_to_delete.append(dashboard) + + self.assertEqual(dashboard.name, name) + self.assertEqual(dashboard.description, description) + # This is a bit obtuse, but Jira mutates the type on this + # object after the fact. `authenticated` corresponds to `loggedin`. + self.assertEqual(dashboard.sharePermissions[0].type, "loggedin") + + # The system dashboard always has the ID `10000`, just + # ensuring we actually have a + self.assertGreater(int(dashboard.id), 10000) + + @only_run_on_cloud + @allow_on_cloud + def test_update_dashboard(self): + updated_name = "changed" + name = rndstr() + description = rndstr() + share_permissions = [{"type": "authenticated"}] + + dashboard = self.jira.create_dashboard( + name=name, description=description, share_permissions=share_permissions + ) + self.assertIsInstance(dashboard, Dashboard) + self.dashboards_to_delete.append(dashboard) + + dashboard.update(name=updated_name) + self.assertEqual(dashboard.name, updated_name) + + @only_run_on_cloud + @allow_on_cloud + def test_delete_dashboard(self): + dashboard = self.jira.create_dashboard(name="to_delete") + dashboard_id = dashboard.id + delete_response = dashboard.delete() + self.assertEqual(delete_response.status_code, 204) + + with pytest.raises(JIRAError) as ex: + self.jira.dashboard(dashboard_id) + + self.assertEqual(ex.value.status_code, 404) + self.assertEqual( + ex.value.text, f"The dashboard with id '{dashboard_id}' does not exist." + ) + + @only_run_on_cloud + @allow_on_cloud + def test_copy_dashboard(self): + original_dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(original_dashboard) + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + + original_gadget = self.jira.add_gadget_to_dashboard( + original_dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + original_dashboard = self.jira.dashboard(original_dashboard.id) + + copied_dashboard = self.jira.copy_dashboard( + original_dashboard.id, name=rndstr() + ) + copied_dashboard = self.jira.dashboard(copied_dashboard.id) + self.assertIsInstance(copied_dashboard, Dashboard) + self.dashboards_to_delete.append(copied_dashboard) + + self.assertEqual(len(original_dashboard.gadgets), len(copied_dashboard.gadgets)) + self.assertEqual(original_gadget.color, copied_dashboard.gadgets[0].color) + self.assertEqual(original_gadget.uri, copied_dashboard.gadgets[0].uri) + + @only_run_on_cloud + @allow_on_cloud + def test_all_dashboard_gadgets(self): + # This is a super basic test. We can't really rely on the fact + # that the gadgets available at any given moment will be specifically represented + # here and it would be silly to have to update the tests to adjust for that if + # the starting list ever changed. + gadgets = self.jira.all_dashboard_gadgets() + self.assertGreater(len(gadgets), 0) + self.assertIsInstance(gadgets[0], DashboardGadget) + + @only_run_on_cloud + @allow_on_cloud + def test_dashboard_gadgets(self): + gadget_count = 3 + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + for _ in range(0, gadget_count): + self.jira.add_gadget_to_dashboard( + dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + + dashboard_gadgets = self.jira.dashboard_gadgets(dashboard.id) + self.assertEqual(len(dashboard_gadgets), gadget_count) + + for dashboard_gadget in dashboard_gadgets: + self.assertIsInstance(dashboard_gadget, DashboardGadget) + + @only_run_on_cloud + @allow_on_cloud + def test_update_dashboard_automatic_refresh_minutes(self): + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + response = self.jira.update_dashboard_automatic_refresh_minutes( + dashboard.id, 10 + ) + self.assertEqual(response.status_code, 204) + response = self.jira.update_dashboard_automatic_refresh_minutes(dashboard.id, 0) + self.assertEqual(response.status_code, 204) + + @only_run_on_cloud + @allow_on_cloud + def test_add_gadget_to_dashboard(self): + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + gadget = self.jira.add_gadget_to_dashboard( + dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + + dashboard = self.jira.dashboard(dashboard.id) + self.assertEqual(dashboard.gadgets[0], gadget) + self.assertIsInstance(dashboard.gadgets[0], DashboardGadget) + + @only_run_on_cloud + @allow_on_cloud + def test_remove_gadget_from_dashboard(self): + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + gadget = self.jira.add_gadget_to_dashboard( + dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + + dashboard = self.jira.dashboard(dashboard.id) + self.assertEqual(len(dashboard.gadgets), 1) + self.assertEqual(dashboard.gadgets[0], gadget) + + gadget.delete(dashboard.id) + dashboard = self.jira.dashboard(dashboard.id) + self.assertEqual(len(dashboard.gadgets), 0) + + @only_run_on_cloud + @allow_on_cloud + def test_update_gadget(self): + new_color = "green" + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + gadget = self.jira.add_gadget_to_dashboard( + dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + + gadget = gadget.update(dashboard.id, color=new_color) + self.assertEqual(gadget.color, new_color) + self.assertEqual(gadget.raw["color"], new_color) + self.assertIsInstance(gadget, DashboardGadget) + + @only_run_on_cloud + @allow_on_cloud + def test_dashboard_item_property_keys(self): + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + gadget = self.jira.add_gadget_to_dashboard( + dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + + dashboard_item_property_keys = self.jira.dashboard_item_property_keys( + dashboard.id, gadget.id + ) + self.assertEqual(len(dashboard_item_property_keys), 0) + + item_property_payload = { + "filterId": self.filter.id, + "columnNames": self.dashboard_item_column_names, + "num": self.dashboard_item_num, + "refresh": self.dashboard_item_refresh, + } + self.jira.set_dashboard_item_property( + dashboard.id, + gadget.id, + self.dashboard_item_expected_key, + value=item_property_payload, + ) + + dashboard_item_property_keys = self.jira.dashboard_item_property_keys( + dashboard.id, gadget.id + ) + self.assertEqual(len(dashboard_item_property_keys), 1) + self.assertEqual( + dashboard_item_property_keys[0].key, self.dashboard_item_expected_key + ) + self.assertIsInstance(dashboard_item_property_keys[0], DashboardItemPropertyKey) + + delete_response = dashboard_item_property_keys[0].delete() + self.assertEqual(delete_response.status_code, 204) + + dashboard_item_property_keys = self.jira.dashboard_item_property_keys( + dashboard.id, gadget.id + ) + self.assertEqual(len(dashboard_item_property_keys), 0) + + @only_run_on_cloud + @allow_on_cloud + def test_dashboard_item_properties(self): + dashboard = self.jira.create_dashboard( + name=rndstr(), share_permissions=[{"type": "authenticated"}] + ) + self.dashboards_to_delete.append(dashboard) + + available_gadgets = self.jira.all_dashboard_gadgets() + filter_gadget = next( + gadget for gadget in available_gadgets if gadget.title == self.gadget_title + ) + gadget = self.jira.add_gadget_to_dashboard( + dashboard.id, + color="blue", + ignore_uri_and_module_key_validation=True, + uri=filter_gadget.uri, + ) + + item_property_payload = { + "filterId": self.filter.id, + "columnNames": self.dashboard_item_column_names, + "num": self.dashboard_item_num, + "refresh": self.dashboard_item_refresh, + } + dashboard_item_property = self.jira.set_dashboard_item_property( + dashboard.id, + gadget.id, + self.dashboard_item_expected_key, + value=item_property_payload, + ) + + dashboard = self.jira.dashboard(dashboard.id) + self.assertEqual( + dashboard.gadgets[0].item_properties[0], dashboard_item_property + ) + self.assertIsInstance(dashboard_item_property, DashboardItemProperty) + + updated_item_property_payload = {"num": 10} + updated_dashboard_item_property = dashboard_item_property.update( + dashboard.id, gadget.id, value=updated_item_property_payload + ) + self.assertEqual( + updated_dashboard_item_property.value.num, + updated_item_property_payload["num"], + ) + + delete_response = updated_dashboard_item_property.delete( + dashboard.id, gadget.id + ) + self.assertEqual(delete_response.status_code, 204) + + @only_run_on_cloud + @allow_on_cloud + @mock.patch("requests.Session.request") + def test_set_dashboard_item_property_not_201_response(self, mocked_request): + mocked_request.return_value = mock.MagicMock(ok=False, status_code=404) + with pytest.raises(JIRAError) as ex: + self.jira.set_dashboard_item_property( + "id", "item_id", "config", {"this": "that"} + ) + + assert ex.value.status_code == 404 diff --git a/tests/resources/test_generic_resource.py b/tests/resources/test_generic_resource.py index a7a2d3e9f..383e61eb1 100644 --- a/tests/resources/test_generic_resource.py +++ b/tests/resources/test_generic_resource.py @@ -23,6 +23,10 @@ class TestResource: (url_test_case("group?groupname=bla"), jira.resources.Group), (url_test_case("user?username=bla"), jira.resources.User), # Jira Server / Data Center (url_test_case("user?accountId=bla"), jira.resources.User), # Jira Cloud + (url_test_case("api/latest/dashboard/12345"), jira.resources.Dashboard), + (url_test_case("api/latest/dashboard/1/items/1/properties"), jira.resources.DashboardItemPropertyKey), + (url_test_case("api/latest/dashboard/1/items/1/properties/property"), jira.resources.DashboardItemProperty), + (url_test_case("api/latest/dashboard/1/gadget/1"), jira.resources.DashboardGadget) ], ids=[ "issue", @@ -32,6 +36,10 @@ class TestResource: "group", "user", "user_cloud", + "dashboard", + "dashboard_item_property_key", + "dashboard_item_property", + "dashboard_gadget" ], ) # fmt: on diff --git a/tests/test_client.py b/tests/test_client.py index ddfb278fe..e9bf534f2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,13 +1,14 @@ from __future__ import annotations import getpass +import logging from unittest import mock import pytest import requests.sessions import jira.client -from jira.exceptions import JIRAError +from jira.exceptions import JIRAError, NotJIRAInstanceError from tests.conftest import JiraTestManager, get_unique_project_name @@ -51,6 +52,75 @@ def slug(request: pytest.FixtureRequest, cl_admin: jira.client.JIRA): pass +@pytest.fixture(scope="session") +def stream_logger(): + logger = logging.getLogger("test_logger") + logger.addHandler(logging.StreamHandler()) + return logger + + +@pytest.fixture(scope="session") +def mock_not_jira_client(stream_logger): + class MockClient: + def __init__(self, is_cloud=False): + self.is_cloud = is_cloud + self.log = stream_logger + + @property + def _is_cloud(self): + return self.is_cloud + + @jira.client.cloud_api + def mock_cloud_only_method(self, *args, **kwargs): + return args, kwargs + + @jira.client.experimental_atlassian_api + def mock_experimental_method(self, *args, **kwargs): + return args, kwargs + + @jira.client.experimental_atlassian_api + def mock_method_raises_jira_error(self, *args, **kwargs): + raise JIRAError(**kwargs) + + return MockClient + + +@pytest.fixture(scope="session") +def mock_jira_client(stream_logger): + class MockClient(jira.client.JIRA): + def __init__(self, is_cloud=False): + self.is_cloud = is_cloud + self.log = stream_logger + + @property + def _is_cloud(self): + return self.is_cloud + + @jira.client.cloud_api + def mock_cloud_only_method(self, *args, **kwargs): + return args, kwargs + + @jira.client.experimental_atlassian_api + def mock_experimental_method(self, *args, **kwargs): + return args, kwargs + + @jira.client.experimental_atlassian_api + def mock_method_raises_jira_error(self, *args, **kwargs): + raise JIRAError(**kwargs) + + return MockClient + + +@pytest.fixture(scope="session") +def mock_response(): + class MockResponse: + def __init__(self, status_code=404): + self.status_code = status_code + self.url = "some/url/that/does/not/exist" + + return MockResponse + + def test_delete_project(cl_admin, cl_normal, slug): assert cl_admin.delete_project(slug) @@ -246,3 +316,75 @@ def test_cookie_auth_retry(): ) # THEN: We don't get a RecursionError and only call the reset_function once mock_reset_func.assert_called_once() + + +@pytest.mark.parametrize( + "mock_client_method", ["mock_cloud_only_method", "mock_experimental_method"] +) +def test_not_cloud_instance(mock_not_jira_client, mock_client_method): + client = mock_not_jira_client() + with pytest.raises(NotJIRAInstanceError) as exc: + getattr(client, mock_client_method)() + + assert str(exc.value) == ( + "The first argument of this function must be an instance of type " + f"JIRA. Instance Type: {mock_not_jira_client().__class__.__name__}" + ) + + +@mock.patch("requests.Session.request") +def test_cloud_api(mock_request, mock_jira_client): + mock_client = mock_jira_client(is_cloud=True) + out = mock_client.mock_cloud_only_method("one", two="three") + assert out is not None + + +@mock.patch("requests.Session.request") +def test_cloud_api_not_cloud_server(mock_request, mock_jira_client, caplog): + mock_client = mock_jira_client() + mock_client.mock_cloud_only_method() + assert caplog.messages[0] == ( + "This functionality is not available on Jira Data Center (Server) version." + ) + + +@mock.patch("requests.Session.request") +def test_experimental(mock_request, mock_jira_client): + out = mock_jira_client().mock_experimental_method("one", two="three") + assert out is not None + + +@pytest.mark.parametrize("http_status_code", [404, 405]) +@mock.patch("requests.Session.request") +def test_experimental_missing_or_not_allowed( + mock_request, mock_jira_client, mock_response, http_status_code, caplog +): + mock_response = mock_response(status_code=http_status_code) + response = mock_jira_client().mock_method_raises_jira_error( + response=mock_response, + request=mock_response, + status_code=mock_response.status_code, + ) + assert response is None + assert caplog.messages[0] == ( + f"Functionality at path {mock_response.url} is/was experimental. Status Code: " + f"{mock_response.status_code}" + ) + + +@mock.patch("requests.Session.request") +def test_experimental_non_200_not_404_405( + mock_request, mock_jira_client, mock_response +): + status_code = 400 + mock_response = mock_response(status_code=status_code) + + with pytest.raises(JIRAError) as ex: + mock_jira_client().mock_method_raises_jira_error( + response=mock_response, + request=mock_response, + status_code=mock_response.status_code, + ) + + assert ex.value.status_code == status_code + assert isinstance(ex.value, JIRAError)