Related to https://github.com/grafana/oncall/pull/5116 Simplifies the db migration removing the `DeclaredIncident` model + FK setup but keeping the other changes (adding `severity` field for escalation policy, and "Declare incident" step, which is disabled). In this way deployments for which the original migration was run, this won't be applied and they will be in sync with the migration status (eventually a manual step may be needed to remove the table and FK, which won't be used for now).
165 lines
5.2 KiB
Python
165 lines
5.2 KiB
Python
import typing
|
|
from json import JSONDecodeError
|
|
from urllib.parse import urljoin
|
|
|
|
import requests
|
|
from django.conf import settings
|
|
|
|
from common.constants.plugin_ids import PluginID
|
|
|
|
|
|
class IncidentDetails(typing.TypedDict):
|
|
# https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/incident/api/reference/#getincidentresponse
|
|
createdByUser: typing.Dict
|
|
createdTime: str
|
|
durationSeconds: int
|
|
heroImagePath: str
|
|
incidentEnd: str
|
|
incidentID: str
|
|
incidentMembership: typing.Dict
|
|
incidentStart: str
|
|
isDrill: bool
|
|
labels: typing.List[dict]
|
|
overviewURL: str
|
|
severity: str
|
|
status: str
|
|
summary: str
|
|
taskList: typing.Dict
|
|
title: str
|
|
|
|
|
|
class SeverityDetails(typing.TypedDict):
|
|
severityID: str
|
|
orgID: str
|
|
displayLabel: str
|
|
level: int
|
|
iconName: str
|
|
description: str
|
|
darkColor: str
|
|
lightColor: str
|
|
archivedAt: str
|
|
archivedByUserID: str | None
|
|
deletedAt: str
|
|
deletedByUserID: str | None
|
|
createdAt: str
|
|
updatedAt: str
|
|
|
|
|
|
class ActivityItemDetails(typing.TypedDict):
|
|
# https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/incident/api/reference/#addactivityresponse
|
|
activityItemID: str
|
|
activityKind: str
|
|
attachments: typing.List[dict]
|
|
body: str
|
|
createdTime: str
|
|
eventTime: str
|
|
fieldValues: typing.Dict[str, str]
|
|
immutable: bool
|
|
incidentID: str
|
|
relevance: str
|
|
subjectUser: typing.Dict[str, str]
|
|
tags: typing.List[str]
|
|
url: str
|
|
user: typing.Dict[str, str]
|
|
|
|
|
|
class IncidentAPIException(Exception):
|
|
def __init__(self, status, url, msg="", method="GET"):
|
|
self.url = url
|
|
self.status = status
|
|
self.method = method
|
|
self.msg = msg
|
|
|
|
def __str__(self):
|
|
return f"IncidentAPIException: status={self.status} url={self.url} method={self.method}"
|
|
|
|
|
|
TIMEOUT = 5
|
|
DEFAULT_INCIDENT_SEVERITY = "Pending"
|
|
DEFAULT_INCIDENT_STATUS = "active"
|
|
DEFAULT_ACTIVITY_KIND = "userNote"
|
|
|
|
|
|
class IncidentAPIClient:
|
|
INCIDENT_BASE_PATH = f"/api/plugins/{PluginID.INCIDENT}/resources/"
|
|
|
|
def __init__(self, api_url: str, api_token: str) -> None:
|
|
self.api_token = api_token
|
|
self.api_url = urljoin(api_url, self.INCIDENT_BASE_PATH)
|
|
|
|
@property
|
|
def _request_headers(self):
|
|
return {"User-Agent": settings.GRAFANA_COM_USER_AGENT, "Authorization": f"Bearer {self.api_token}"}
|
|
|
|
def _check_response(self, response: requests.models.Response):
|
|
message = ""
|
|
|
|
if response.status_code >= 400:
|
|
try:
|
|
error_data = response.json()
|
|
message = error_data.get("error", response.reason)
|
|
except JSONDecodeError:
|
|
message = response.reason
|
|
|
|
raise IncidentAPIException(
|
|
status=response.status_code,
|
|
url=response.request.url,
|
|
msg=message,
|
|
method=response.request.method,
|
|
)
|
|
|
|
def create_incident(
|
|
self,
|
|
title: str,
|
|
severity: str = DEFAULT_INCIDENT_SEVERITY,
|
|
status: str = DEFAULT_INCIDENT_STATUS,
|
|
attachCaption: str = "",
|
|
attachURL: str = "",
|
|
) -> typing.Tuple[IncidentDetails, requests.models.Response]:
|
|
endpoint = "api/v1/IncidentsService.CreateIncident"
|
|
url = self.api_url + endpoint
|
|
# NOTE: invalid severity will raise a 500 error
|
|
response = requests.post(
|
|
url,
|
|
json={
|
|
"title": title,
|
|
"severity": severity,
|
|
"attachCaption": attachCaption,
|
|
"attachURL": attachURL,
|
|
"status": status,
|
|
},
|
|
timeout=TIMEOUT,
|
|
headers=self._request_headers,
|
|
)
|
|
self._check_response(response)
|
|
return response.json().get("incident"), response
|
|
|
|
def get_incident(self, incident_id: str) -> typing.Tuple[IncidentDetails, requests.models.Response]:
|
|
endpoint = "api/v1/IncidentsService.GetIncident"
|
|
url = self.api_url + endpoint
|
|
response = requests.post(url, json={"incidentID": incident_id}, timeout=TIMEOUT, headers=self._request_headers)
|
|
self._check_response(response)
|
|
return response.json().get("incident"), response
|
|
|
|
def get_severities(self) -> typing.Tuple[typing.List[SeverityDetails], requests.models.Response]:
|
|
# NOTE: internal endpoint
|
|
endpoint = "api/SeveritiesService.GetOrgSeverities"
|
|
url = self.api_url + endpoint
|
|
# pass empty json payload otherwise it will return a 500 response
|
|
response = requests.post(url, timeout=TIMEOUT, headers=self._request_headers, json={})
|
|
self._check_response(response)
|
|
return response.json().get("severities"), response
|
|
|
|
def add_activity(
|
|
self, incident_id: str, body: str, kind: str = DEFAULT_ACTIVITY_KIND
|
|
) -> typing.Tuple[ActivityItemDetails, requests.models.Response]:
|
|
endpoint = "api/v1/ActivityService.AddActivity"
|
|
url = self.api_url + endpoint
|
|
response = requests.post(
|
|
url,
|
|
json={"incidentID": incident_id, "activityKind": kind, "body": body},
|
|
timeout=TIMEOUT,
|
|
headers=self._request_headers,
|
|
)
|
|
self._check_response(response)
|
|
return response.json().get("activityItem"), response
|