Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ISSUE-1836: Add Dashboard Support #1837

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading