Skip to content

Commit fbcabe1

Browse files
authored
Add Dashboard Support (#1836, #1837)
`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.
1 parent 4999a76 commit fbcabe1

8 files changed

+1069
-23
lines changed

jira/client.py

+365-19
Large diffs are not rendered by default.

jira/exceptions.py

+12
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import tempfile
5+
from typing import Any
56

67
from requests import Response
78

@@ -69,3 +70,14 @@ def __str__(self) -> str:
6970
t += f"\n\t{details}"
7071

7172
return t
73+
74+
75+
class NotJIRAInstanceError(Exception):
76+
"""Raised in the case an object is not a JIRA instance."""
77+
78+
def __init__(self, instance: Any):
79+
msg = (
80+
"The first argument of this function must be an instance of type "
81+
f"JIRA. Instance Type: {instance.__class__.__name__}"
82+
)
83+
super().__init__(msg)

jira/resources.py

+158-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
This module implements the Resource classes that translate JSON from Jira REST
44
resources into usable objects.
55
"""
6+
67
from __future__ import annotations
78

89
import json
@@ -15,7 +16,7 @@
1516
from requests.structures import CaseInsensitiveDict
1617

1718
from jira.resilientsession import ResilientSession, parse_errors
18-
from jira.utils import json_loads, threaded_requests
19+
from jira.utils import json_loads, remove_empty_attributes, threaded_requests
1920

2021
if TYPE_CHECKING:
2122
from jira.client import JIRA
@@ -37,7 +38,10 @@ class AnyLike:
3738
"Attachment",
3839
"Component",
3940
"Dashboard",
41+
"DashboardItemProperty",
42+
"DashboardItemPropertyKey",
4043
"Filter",
44+
"DashboardGadget",
4145
"Votes",
4246
"PermissionScheme",
4347
"Watchers",
@@ -239,7 +243,7 @@ def __eq__(self, other: Any) -> bool:
239243

240244
def find(
241245
self,
242-
id: tuple[str, str] | int | str,
246+
id: tuple[str, ...] | int | str,
243247
params: dict[str, str] | None = None,
244248
):
245249
"""Finds a resource based on the input parameters.
@@ -552,8 +556,157 @@ def __init__(
552556
Resource.__init__(self, "dashboard/{0}", options, session)
553557
if raw:
554558
self._parse_raw(raw)
559+
self.gadgets: list[DashboardGadget] = []
560+
self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw)
561+
562+
563+
class DashboardItemPropertyKey(Resource):
564+
"""A jira dashboard item property key."""
565+
566+
def __init__(
567+
self,
568+
options: dict[str, str],
569+
session: ResilientSession,
570+
raw: dict[str, Any] = None,
571+
):
572+
Resource.__init__(self, "dashboard/{0}/items/{1}/properties", options, session)
573+
if raw:
574+
self._parse_raw(raw)
575+
self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw)
576+
577+
578+
class DashboardItemProperty(Resource):
579+
"""A jira dashboard item."""
580+
581+
def __init__(
582+
self,
583+
options: dict[str, str],
584+
session: ResilientSession,
585+
raw: dict[str, Any] = None,
586+
):
587+
Resource.__init__(
588+
self, "dashboard/{0}/items/{1}/properties/{2}", options, session
589+
)
590+
if raw:
591+
self._parse_raw(raw)
592+
self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw)
593+
594+
def update( # type: ignore[override] # incompatible supertype ignored
595+
self, dashboard_id: str, item_id: str, value: dict[str, Any]
596+
) -> DashboardItemProperty:
597+
"""Update this resource on the server.
598+
599+
Keyword arguments are marshalled into a dict before being sent. If this resource doesn't support ``PUT``, a :py:exc:`.JIRAError`
600+
will be raised; subclasses that specialize this method will only raise errors in case of user error.
601+
602+
Args:
603+
dashboard_id (str): The ``id`` if the dashboard.
604+
item_id (str): The id of the dashboard item (``DashboardGadget``) to target.
605+
value (dict[str, Any]): The value of the targeted property key.
606+
607+
Returns:
608+
DashboardItemProperty
609+
"""
610+
options = self._options.copy()
611+
options[
612+
"path"
613+
] = f"dashboard/{dashboard_id}/items/{item_id}/properties/{self.key}"
614+
self.raw["value"].update(value)
615+
self._session.put(self.JIRA_BASE_URL.format(**options), self.raw["value"])
616+
617+
return DashboardItemProperty(self._options, self._session, raw=self.raw)
618+
619+
def delete(self, dashboard_id: str, item_id: str) -> Response: # type: ignore[override] # incompatible supertype ignored
620+
"""Delete dashboard item property.
621+
622+
Args:
623+
dashboard_id (str): The ``id`` of the dashboard.
624+
item_id (str): The ``id`` of the dashboard item (``DashboardGadget``).
625+
626+
627+
Returns:
628+
Response
629+
"""
630+
options = self._options.copy()
631+
options[
632+
"path"
633+
] = f"dashboard/{dashboard_id}/items/{item_id}/properties/{self.key}"
634+
635+
return self._session.delete(self.JIRA_BASE_URL.format(**options))
636+
637+
638+
class DashboardGadget(Resource):
639+
"""A jira dashboard gadget."""
640+
641+
def __init__(
642+
self,
643+
options: dict[str, str],
644+
session: ResilientSession,
645+
raw: dict[str, Any] = None,
646+
):
647+
Resource.__init__(self, "dashboard/{0}/gadget/{1}", options, session)
648+
if raw:
649+
self._parse_raw(raw)
650+
self.item_properties: list[DashboardItemProperty] = []
555651
self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw)
556652

653+
def update( # type: ignore[override] # incompatible supertype ignored
654+
self,
655+
dashboard_id: str,
656+
color: str | None = None,
657+
position: dict[str, Any] | None = None,
658+
title: str | None = None,
659+
) -> DashboardGadget:
660+
"""Update this resource on the server.
661+
662+
Keyword arguments are marshalled into a dict before being sent. If this resource doesn't support ``PUT``, a :py:exc:`.JIRAError`
663+
will be raised; subclasses that specialize this method will only raise errors in case of user error.
664+
665+
Args:
666+
dashboard_id (str): The ``id`` of the dashboard to add the gadget to `required`.
667+
color (str): The color of the gadget, should be one of: blue, red, yellow,
668+
green, cyan, purple, gray, or white.
669+
ignore_uri_and_module_key_validation (bool): Whether to ignore the
670+
validation of the module key and URI. For example, when a gadget is created
671+
that is part of an application that is not installed.
672+
position (dict[str, int]): A dictionary containing position information like -
673+
`{"column": 0, "row", 1}`.
674+
title (str): The title of the gadget.
675+
676+
Returns:
677+
``DashboardGadget``
678+
"""
679+
data = remove_empty_attributes(
680+
{"color": color, "position": position, "title": title}
681+
)
682+
options = self._options.copy()
683+
options["path"] = f"dashboard/{dashboard_id}/gadget/{self.id}"
684+
685+
self._session.put(self.JIRA_BASE_URL.format(**options), json=data)
686+
options["path"] = f"dashboard/{dashboard_id}/gadget"
687+
688+
return next(
689+
DashboardGadget(self._options, self._session, raw=gadget)
690+
for gadget in self._session.get(
691+
self.JIRA_BASE_URL.format(**options)
692+
).json()["gadgets"]
693+
if gadget["id"] == self.id
694+
)
695+
696+
def delete(self, dashboard_id: str) -> Response: # type: ignore[override] # incompatible supertype ignored
697+
"""Delete gadget from dashboard.
698+
699+
Args:
700+
dashboard_id (str): The ``id`` of the dashboard.
701+
702+
Returns:
703+
Response
704+
"""
705+
options = self._options.copy()
706+
options["path"] = f"dashboard/{dashboard_id}/gadget/{self.id}"
707+
708+
return self._session.delete(self.JIRA_BASE_URL.format(**options))
709+
557710

558711
class Field(Resource):
559712
"""An issue field.
@@ -1492,6 +1645,9 @@ def dict2resource(
14921645
r"component/[^/]+$": Component,
14931646
r"customFieldOption/[^/]+$": CustomFieldOption,
14941647
r"dashboard/[^/]+$": Dashboard,
1648+
r"dashboard/[^/]+/items/[^/]+/properties+$": DashboardItemPropertyKey,
1649+
r"dashboard/[^/]+/items/[^/]+/properties/[^/]+$": DashboardItemProperty,
1650+
r"dashboard/[^/]+/gadget/[^/]+$": DashboardGadget,
14951651
r"filter/[^/]$": Filter,
14961652
r"issue/[^/]+$": Issue,
14971653
r"issue/[^/]+/comment/[^/]+$": Comment,

jira/utils/__init__.py

+13
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Jira utils used internally."""
2+
23
from __future__ import annotations
34

45
import threading
@@ -79,3 +80,15 @@ def json_loads(resp: Response | None) -> Any:
7980
if not resp.text:
8081
return {}
8182
raise
83+
84+
85+
def remove_empty_attributes(data: dict[str, Any]) -> dict[str, Any]:
86+
"""A convenience function to remove key/value pairs with `None` for a value.
87+
88+
Args:
89+
data: A dictionary.
90+
91+
Returns:
92+
Dict[str, Any]: A dictionary with no `None` key/value pairs.
93+
"""
94+
return {key: val for key, val in data.items() if val is not None}

tests/conftest.py

+4
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727

2828

2929
allow_on_cloud = pytest.mark.allow_on_cloud
30+
only_run_on_cloud = pytest.mark.skipif(
31+
os.environ.get("CI_JIRA_TYPE", "Server").upper() != "CLOUD",
32+
reason="Functionality only available on Jira Cloud",
33+
)
3034
broken_test = pytest.mark.xfail
3135

3236

0 commit comments

Comments
 (0)