Skip to content

Commit

Permalink
Add Dashboard Support (#1836, #1837)
Browse files Browse the repository at this point in the history
`jira/client.py`
----------------

* Added `cloud_api` convenience decorator for client methods that make calls
 that are only available on the `cloud_api` api. It checks the `client`
 instance to see if it `_is_cloud`. If not, it logs a warning and returns
 `None`. This was the convention seen on other endpoints on the `client`.
* Added `experimental_atlassian_api` convenience decorator for client methods that
 make calls that are experimental. It attempts to run the client method,
 if a `JIRAError` is raised that has a response object, the response is
 checked for a status code in `[404, 405]` indicating either the path no
 longer accepts the HTTP verb or no longer exists, and then logs a
 warning and returns `None`. Otherwise it re-raises the error
* Imported `DashboardItemProperty`, `DashboardItemPropertyKey`, and
 `DashboardGadget` resources to client for use in new methods.
* Updated the `dashboards` method to include the `gadgets` that exist on
 a given dashboard. This is a logical association that makes sense, but
 isn't directly exposed in the API.
* Added `create_dashboard` method. It creates a dashboard via the API
 and returns a `Dashboard` object.
* Added `copy_dashboard` method.
* Added `update_dashboard_automatic_refresh_seconds` method. This calls
 the `internal` API, which is why it's decorated with `experimental_atlassian_api` and
 `cloud_api`. This might change in the future, but it really is a handy thing
 to have, otherwise, the user has to configure this in the web interface.

---
* Added `dashboard_item_property` method. This is available on both
 `cloud_api` and `server`.
* Added `dashboard_item_property_keys` method. This is available on both
 `cloud_api` and `server`.
* Added `set_dashboard_item_property` method. This is available on both
 `cloud_api` and `server`.
---
^^ These methods all provide a means of adding arbitrary metadata to
`dashboard_items` (`gadgets`) and/or configure them via specific keys.

* Added `dashboard_gadgets` method. This returns the gadgets associated
with a given dashboard. It also iterates over the `keys` for this
`gadget`'s properties, generating a list of `DashboardItemProperty`
objects that are associated with each gadget. This makes it really easy
for the user to associate which configuration/metadata goes with which
gadget.
* Added `all_dashboard_gadgets` method. This returns a list of from `jira` of all the
`gadgets` that are available to add to any dashboard.
* Added `add_gadget_to_dashboard` method. This allows the user to add
gadgets to a specified dashboard.
* Added the protected method `_get_internal_url`. This is very similar
to `get_url` or `get_latest` url, where `options` are updated to allow
for easy resolution of paths that are on the `internal` `jira` api.
* Updated the `_find_for_resource` typehint on `ids` because it is
possible that a resource requires more than `2` ids to resolve it's url.

jira/resources.py
-----------------

* Added the new resources `DashboardItemProperty`,
 `DashboardItemPropertyKey`, and `Gadget` to the `__all__` list so they
 are represented.
* Added a `gadgets` attribute to the `Dashboard` resource to house
 `gadget` references.
* Added `DashboardItemPropertyKey` resource.
* Added `DashboardItemProperty` resource. The `update` and `delete`
 methods are overridden here because it does not have a `self` attribute.
 This is kind of in an in between space as far as being considered a
 resource, but for ease of use as an interface, it makes sense for it to
 be considered.
* Added `DashboardGadget` resource. It too has overridden `update` and `delete`
 methods for the aforementioned reasons.

jira/utils/__init__.py
----------------------

* Added `remove_empty_attributes` convenience method. I found myself
 having to remove empty attributes or add a lot of branching in order to
 accommodate optional payload parameters or path parameters. This
 function made that easier.

jira/utils/exceptions.py
------------------------

* Created `NotJIRAInstanceError` exception. This is raised in the case
 one of the convenience decorators utilized on a client method is
 improperly applied to some other kind of object.
  • Loading branch information
jpavlav authored Mar 22, 2024
1 parent 4999a76 commit fbcabe1
Show file tree
Hide file tree
Showing 8 changed files with 1,069 additions and 23 deletions.
384 changes: 365 additions & 19 deletions jira/client.py

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions jira/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import tempfile
from typing import Any

from requests import Response

Expand Down Expand Up @@ -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)
160 changes: 158 additions & 2 deletions jira/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -37,7 +38,10 @@ class AnyLike:
"Attachment",
"Component",
"Dashboard",
"DashboardItemProperty",
"DashboardItemPropertyKey",
"Filter",
"DashboardGadget",
"Votes",
"PermissionScheme",
"Watchers",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions jira/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Jira utils used internally."""

from __future__ import annotations

import threading
Expand Down Expand Up @@ -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}
4 changes: 4 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading

0 comments on commit fbcabe1

Please sign in to comment.