v1.9.0
This commit is contained in:
commit
d8b4d10406
35 changed files with 867 additions and 62 deletions
190
docs/sources/oncall-api-reference/escalation.md
Normal file
190
docs/sources/oncall-api-reference/escalation.md
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
---
|
||||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/escalation/
|
||||
title: Escalation HTTP API
|
||||
weight: 1200
|
||||
refs:
|
||||
users:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/users
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/users
|
||||
teams:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/teams
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/teams
|
||||
manual-paging:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/configure/integrations/references/manual
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/configure/integrations/references/manual
|
||||
---
|
||||
|
||||
# Escalation HTTP API
|
||||
|
||||
See [Manual paging integration](ref:manual-paging) for more background on how escalating to a team or user(s) works.
|
||||
|
||||
## Escalate to a set of users
|
||||
|
||||
For more details about how to fetch a user's Grafana OnCall ID, refer to the [Users](ref:users) public API documentation.
|
||||
|
||||
```shell
|
||||
curl "{{API_URL}}/api/v1/escalation/" \
|
||||
--request POST \
|
||||
--header "Authorization: meowmeowmeow" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{
|
||||
"title": "We are seeing a network outage in the datacenter",
|
||||
"message": "I need help investigating, can you join the investigation?",
|
||||
"source_url": "https://github.com/myorg/myrepo/issues/123",
|
||||
"users": [
|
||||
{
|
||||
"id": "U281SN24AVVJX",
|
||||
"important": false
|
||||
},
|
||||
{
|
||||
"id": "U5AKCVNDEDUE7",
|
||||
"important": true
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
The above command returns JSON structured in the following way:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "IZHCC4GTNPZ93",
|
||||
"integration_id": "CC3GZYZNIIEH5",
|
||||
"route_id": "RDN8LITALJXCJ",
|
||||
"alerts_count": 1,
|
||||
"state": "firing",
|
||||
"created_at": "2024-08-15T18:05:36.801215Z",
|
||||
"resolved_at": null,
|
||||
"resolved_by": null,
|
||||
"acknowledged_at": null,
|
||||
"acknowledged_by": null,
|
||||
"title": "We're seeing a network outage in the datacenter",
|
||||
"permalinks": {
|
||||
"slack": null,
|
||||
"slack_app": null,
|
||||
"telegram": null,
|
||||
"web": "http://<my_grafana_url>/a/grafana-oncall-app/alert-groups/I5LAZ2MXGPUAH"
|
||||
},
|
||||
"silenced_at": null
|
||||
}
|
||||
```
|
||||
|
||||
## Escalate to a team
|
||||
|
||||
For more details about how to fetch a team's Grafana OnCall ID, refer to the [Teams](ref:teams) public API documentation.
|
||||
|
||||
```shell
|
||||
curl "{{API_URL}}/api/v1/escalation/" \
|
||||
--request POST \
|
||||
--header "Authorization: meowmeowmeow" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{
|
||||
"title": "We are seeing a network outage in the datacenter",
|
||||
"message": "I need help investigating, can you join the investigation?",
|
||||
"source_url": "https://github.com/myorg/myrepo/issues/123",
|
||||
"team": "TI73TDU19W48J"
|
||||
}'
|
||||
```
|
||||
|
||||
The above command returns JSON structured in the following way:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "IZHCC4GTNPZ93",
|
||||
"integration_id": "CC3GZYZNIIEH5",
|
||||
"route_id": "RDN8LITALJXCJ",
|
||||
"alerts_count": 1,
|
||||
"state": "firing",
|
||||
"created_at": "2024-08-15T18:05:36.801215Z",
|
||||
"resolved_at": null,
|
||||
"resolved_by": null,
|
||||
"acknowledged_at": null,
|
||||
"acknowledged_by": null,
|
||||
"title": "We're seeing a network outage in the datacenter",
|
||||
"permalinks": {
|
||||
"slack": null,
|
||||
"slack_app": null,
|
||||
"telegram": null,
|
||||
"web": "http://<my_grafana_url>/a/grafana-oncall-app/alert-groups/I5LAZ2MXGPUAH"
|
||||
},
|
||||
"silenced_at": null
|
||||
}
|
||||
```
|
||||
|
||||
## Escalate to a set of user(s) for an existing Alert Group
|
||||
|
||||
The following shows how you can escalate to a set of user(s) for an existing Alert Group.
|
||||
|
||||
```shell
|
||||
curl "{{API_URL}}/api/v1/escalation/" \
|
||||
--request POST \
|
||||
--header "Authorization: meowmeowmeow" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data '{
|
||||
"alert_group_id": "IZMRNNY8RFS94",
|
||||
"users": [
|
||||
{
|
||||
"id": "U281SN24AVVJX",
|
||||
"important": false
|
||||
},
|
||||
{
|
||||
"id": "U5AKCVNDEDUE7",
|
||||
"important": true
|
||||
}
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
The above command returns JSON structured in the following way:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "IZHCC4GTNPZ93",
|
||||
"integration_id": "CC3GZYZNIIEH5",
|
||||
"route_id": "RDN8LITALJXCJ",
|
||||
"alerts_count": 1,
|
||||
"state": "firing",
|
||||
"created_at": "2024-08-15T18:05:36.801215Z",
|
||||
"resolved_at": null,
|
||||
"resolved_by": null,
|
||||
"acknowledged_at": null,
|
||||
"acknowledged_by": null,
|
||||
"title": "We're seeing a network outage in the datacenter",
|
||||
"permalinks": {
|
||||
"slack": null,
|
||||
"slack_app": null,
|
||||
"telegram": null,
|
||||
"web": "http://<my_grafana_url>/a/grafana-oncall-app/alert-groups/I5LAZ2MXGPUAH"
|
||||
},
|
||||
"silenced_at": null
|
||||
}
|
||||
```
|
||||
|
||||
| Parameter | Unique | Required | Description |
|
||||
| -------------------- | :----: | :--------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `title` | No | No | Name of the Alert Group that will be created |
|
||||
| `message` | No | No | Content of the Alert Group that will be created |
|
||||
| `source_url` | No | No | Value that will be added in the Alert's payload as `oncall.permalink`. This can be useful to have the source URL/button autopopulated with a URL of interest. |
|
||||
| `team` | No | Yes (see [Things to Note](#things-to-note)) | Grafana OnCall team ID. If specified, will use the "Direct Paging" Integration associated with this Grafana OnCall team, to create the Alert Group. |
|
||||
| `users` | No | Yes (see [Things to Note](#things-to-note)) | List of user(s) to escalate to. See above request example for object schema. `id` represents the Grafana OnCall user's ID. `important` is a boolean representing whether to escalate the Alert Group using this user's default or important personal notification policy. |
|
||||
| `alert_group_id` | No | No | If specified, will escalate the specified users for this Alert Group. |
|
||||
|
||||
## Things to note
|
||||
|
||||
- `team` and `users` are mutually exclusive in the request payload. If you would like to escalate to a team AND user(s),
|
||||
first escalate to a team, then using the Alert Group ID returned in the response payload, add the required users to the
|
||||
existing Alert Group
|
||||
- `alert_group_id` is mutually exclusive with `title`, `message`, and `source_url`. Practically speaking this means that
|
||||
if you are trying to escalate to a set of users on an existing Alert Group, you cannot update the `title`, `message`, or
|
||||
`source_url` of that Alert Group
|
||||
- If escalating to a set of users for an existing Alert Group, the Alert Group cannot be in a resolved state
|
||||
|
||||
**HTTP request**
|
||||
|
||||
`POST {{API_URL}}/api/v1/escalation/`
|
||||
73
docs/sources/oncall-api-reference/organizations.md
Normal file
73
docs/sources/oncall-api-reference/organizations.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/organizations/
|
||||
title: Grafana OnCall organizations HTTP API
|
||||
weight: 1500
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Grafana OnCall organizations HTTP API
|
||||
|
||||
## Get an organization
|
||||
|
||||
This endpoint retrieves the organization object.
|
||||
|
||||
```shell
|
||||
curl "{{API_URL}}/api/v1/organizations/O53AAGWFBPE5W/" \
|
||||
--request GET \
|
||||
--header "Authorization: meowmeowmeow" \
|
||||
--header "Content-Type: application/json"
|
||||
````
|
||||
|
||||
The above command returns JSON structured in the following way:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "O53AAGWFBPE5W"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP request**
|
||||
|
||||
`GET {{API_URL}}/api/v1/organizations/<ORGANIZATION_ID>/`
|
||||
|
||||
| Parameter | Unique | Description |
|
||||
| ---------- | :-----: | :----------------------------------------------------------------- |
|
||||
| `id` | Yes | Organization ID |
|
||||
|
||||
## List Organizations
|
||||
|
||||
```shell
|
||||
curl "{{API_URL}}/api/v1/organizations/" \
|
||||
--request GET \
|
||||
--header "Authorization: meowmeowmeow" \
|
||||
--header "Content-Type: application/json"
|
||||
```
|
||||
|
||||
The above command returns JSON structured in the following way:
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": "O53AAGWFBPE5W"
|
||||
}
|
||||
],
|
||||
"page_size": 25,
|
||||
"current_page_number": 1,
|
||||
"total_pages": 1
|
||||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
**HTTP request**
|
||||
|
||||
`GET {{API_URL}}/api/v1/organizations/`
|
||||
86
docs/sources/oncall-api-reference/teams.md
Normal file
86
docs/sources/oncall-api-reference/teams.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
---
|
||||
canonical: https://grafana.com/docs/oncall/latest/oncall-api-reference/teams/
|
||||
title: Grafana OnCall teams HTTP API
|
||||
weight: 1500
|
||||
refs:
|
||||
pagination:
|
||||
- pattern: /docs/oncall/
|
||||
destination: /docs/oncall/<ONCALL_VERSION>/oncall-api-reference/#pagination
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/oncall/oncall-api-reference/#pagination
|
||||
---
|
||||
|
||||
# Grafana OnCall teams HTTP API
|
||||
|
||||
## Get a team
|
||||
|
||||
This endpoint retrieves the team object.
|
||||
|
||||
```shell
|
||||
curl "{{API_URL}}/api/v1/teams/TI73TDU19W48J/" \
|
||||
--request GET \
|
||||
--header "Authorization: meowmeowmeow" \
|
||||
--header "Content-Type: application/json"
|
||||
````
|
||||
|
||||
The above command returns JSON structured in the following way:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "TI73TDU19W48J",
|
||||
"name": "my test team",
|
||||
"email": "",
|
||||
"avatar_url": "/avatar/3f49c15916554246daa714b9bd0ee398"
|
||||
}
|
||||
```
|
||||
|
||||
**HTTP request**
|
||||
|
||||
`GET {{API_URL}}/api/v1/teams/<TEAM_ID>/`
|
||||
|
||||
| Parameter | Unique | Description |
|
||||
| ---------- | :-----: | :----------------------------------------------------------------- |
|
||||
| `id` | Yes/org | Team ID |
|
||||
| `name` | Yes/org | Team name |
|
||||
| `email` | Yes/org | Team e-mail |
|
||||
| `avatar_url` | Yes | Avatar URL of the Grafana team |
|
||||
|
||||
## List Teams
|
||||
|
||||
```shell
|
||||
curl "{{API_URL}}/api/v1/teams/" \
|
||||
--request GET \
|
||||
--header "Authorization: meowmeowmeow" \
|
||||
--header "Content-Type: application/json"
|
||||
```
|
||||
|
||||
The above command returns JSON structured in the following way:
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": "TI73TDU19W48J",
|
||||
"name": "my test team",
|
||||
"email": "",
|
||||
"avatar_url": "/avatar/3f49c15916554246daa714b9bd0ee398"
|
||||
}
|
||||
],
|
||||
"page_size": 50,
|
||||
"current_page_number": 1,
|
||||
"total_pages": 1
|
||||
}
|
||||
```
|
||||
|
||||
> **Note**: The response is [paginated](ref:pagination). You may need to make multiple requests to get all records.
|
||||
|
||||
The following available filter parameter should be provided as a `GET` argument:
|
||||
|
||||
- `name` (Exact match)
|
||||
|
||||
**HTTP request**
|
||||
|
||||
`GET {{API_URL}}/api/v1/teams/`
|
||||
|
|
@ -559,6 +559,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
|
|||
|
||||
user_ids: typing.Set[str] = set()
|
||||
users: typing.Dict[str, PagedUser] = {}
|
||||
organization = self.channel.organization
|
||||
|
||||
log_records = self.log_records.filter(
|
||||
type__in=(AlertGroupLogRecord.TYPE_DIRECT_PAGING, AlertGroupLogRecord.TYPE_UNPAGE_USER)
|
||||
|
|
@ -594,7 +595,7 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
|
|||
"name": user.name,
|
||||
"username": user.username,
|
||||
"avatar": user.avatar_url,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
"important": important,
|
||||
"teams": [{"pk": t.public_primary_key, "name": t.name} for t in user.teams.all()],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,9 +230,10 @@ class AlertGroupLogRecord(models.Model):
|
|||
def render_log_line_json(self):
|
||||
time = humanize.naturaldelta(self.alert_group.started_at - self.created_at)
|
||||
created_at = DateTimeField().to_representation(self.created_at)
|
||||
author = self.author.short() if self.author is not None else None
|
||||
organization = self.alert_group.channel.organization
|
||||
author = self.author.short(organization) if self.author is not None else None
|
||||
|
||||
sf = SlackFormatter(self.alert_group.channel.organization)
|
||||
sf = SlackFormatter(organization)
|
||||
action = sf.format(self.rendered_log_line_action(substitute_author_with_tag=True))
|
||||
action = clean_markup(action)
|
||||
|
||||
|
|
|
|||
|
|
@ -179,9 +179,10 @@ class ResolutionNote(models.Model):
|
|||
def render_log_line_json(self):
|
||||
time = humanize.naturaldelta(self.alert_group.started_at - self.created_at)
|
||||
created_at = DateTimeField().to_representation(self.created_at)
|
||||
author = self.author.short() if self.author is not None else None
|
||||
organization = self.alert_group.channel.organization
|
||||
author = self.author.short(organization) if self.author is not None else None
|
||||
|
||||
sf = SlackFormatter(self.alert_group.channel.organization)
|
||||
sf = SlackFormatter(organization)
|
||||
action = sf.format(self.text)
|
||||
action = clean_markup(action)
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ def convert_prev_shifts_to_new_format(prev_shifts: dict, schedule: "OnCallSchedu
|
|||
"display_name": user.username,
|
||||
"email": user.email,
|
||||
"pk": user.public_primary_key,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(schedule.organization),
|
||||
},
|
||||
)
|
||||
for uid, shift in prev_shifts.items():
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ class AlertGroupListSerializer(
|
|||
if log_record.author is not None and log_record.author.public_primary_key not in users_ids:
|
||||
users.append(log_record.author)
|
||||
users_ids.add(log_record.author.public_primary_key)
|
||||
return UserShortSerializer(users, many=True).data
|
||||
return UserShortSerializer(users, context=self.context, many=True).data
|
||||
|
||||
|
||||
class AlertGroupSerializer(AlertGroupListSerializer):
|
||||
|
|
|
|||
|
|
@ -32,9 +32,11 @@ class UserReferenceSerializer(serializers.Serializer):
|
|||
return attrs
|
||||
|
||||
|
||||
class DirectPagingSerializer(serializers.Serializer):
|
||||
class BasePagingSerializer(serializers.Serializer):
|
||||
context: SerializerContext
|
||||
|
||||
ALLOWS_GRAFANA_INCIDENT_ID = False
|
||||
|
||||
users = UserReferenceSerializer(many=True, required=False, default=list)
|
||||
team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault())
|
||||
|
||||
|
|
@ -44,7 +46,6 @@ class DirectPagingSerializer(serializers.Serializer):
|
|||
title = serializers.CharField(required=False, default=None)
|
||||
message = serializers.CharField(required=False, default=None, allow_null=True)
|
||||
source_url = serializers.URLField(required=False, default=None, allow_null=True)
|
||||
grafana_incident_id = serializers.CharField(required=False, default=None, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
organization = self.context["organization"]
|
||||
|
|
@ -52,13 +53,17 @@ class DirectPagingSerializer(serializers.Serializer):
|
|||
title = attrs["title"]
|
||||
message = attrs["message"]
|
||||
source_url = attrs["source_url"]
|
||||
grafana_incident_id = attrs["grafana_incident_id"]
|
||||
grafana_incident_id = self.ALLOWS_GRAFANA_INCIDENT_ID and attrs.get("grafana_incident_id")
|
||||
|
||||
if alert_group_id and (title or message or source_url or grafana_incident_id):
|
||||
raise serializers.ValidationError(
|
||||
"alert_group_id and (title, message, source_url, grafana_incident_id) are mutually exclusive"
|
||||
f"alert_group_id and (title, message, source_url{', grafana_incident_id' if self.ALLOWS_GRAFANA_INCIDENT_ID else ''}) "
|
||||
"are mutually exclusive"
|
||||
)
|
||||
|
||||
if attrs["users"] and attrs["team"]:
|
||||
raise serializers.ValidationError("users and team are mutually exclusive")
|
||||
|
||||
if alert_group_id:
|
||||
try:
|
||||
attrs["alert_group"] = AlertGroup.objects.get(
|
||||
|
|
@ -68,3 +73,9 @@ class DirectPagingSerializer(serializers.Serializer):
|
|||
raise serializers.ValidationError("Alert group {} does not exist".format(alert_group_id))
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class DirectPagingSerializer(BasePagingSerializer):
|
||||
ALLOWS_GRAFANA_INCIDENT_ID = True
|
||||
|
||||
grafana_incident_id = serializers.CharField(required=False, default=None, allow_null=True)
|
||||
|
|
@ -69,7 +69,8 @@ class ScheduleBaseSerializer(EagerLoadingMixin, serializers.ModelSerializer):
|
|||
def get_on_call_now(self, obj):
|
||||
# Serializer context is set here: apps.api.views.schedule.ScheduleView.get_serializer_context
|
||||
users = self.context["oncall_users"].get(obj, [])
|
||||
return [user.short() for user in users]
|
||||
organization = self.context["request"].auth.organization
|
||||
return [user.short(organization) for user in users]
|
||||
|
||||
def get_number_of_escalation_chains(self, obj):
|
||||
# num_escalation_chains param added in queryset via annotate. Check ScheduleView.get_queryset
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from common.api_helpers.mixins import EagerLoadingMixin
|
|||
|
||||
|
||||
class PolymorphicScheduleSerializer(EagerLoadingMixin, PolymorphicSerializer):
|
||||
SELECT_RELATED = ["organization", "user_group"]
|
||||
SELECT_RELATED = ["organization", "user_group", "team"]
|
||||
|
||||
resource_type_field_name = "type"
|
||||
|
||||
|
|
|
|||
|
|
@ -103,11 +103,14 @@ class ShiftSwapRequestExpandedUsersListSerializer(BaseShiftSwapRequestListSerial
|
|||
def _serialize_user(self, user: "User") -> dict | None:
|
||||
user_data = None
|
||||
if user:
|
||||
organization = (
|
||||
self.context["request"].auth.organization if self.context.get("request") else user.organization
|
||||
)
|
||||
user_data = {
|
||||
"display_name": user.username,
|
||||
"email": user.email,
|
||||
"pk": user.public_primary_key,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
}
|
||||
return user_data
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
|
|||
|
||||
timezone = TimeZoneField(allow_null=True, required=False)
|
||||
avatar = serializers.URLField(source="avatar_url", read_only=True)
|
||||
avatar_full = serializers.URLField(source="avatar_full_url", read_only=True)
|
||||
avatar_full = serializers.SerializerMethodField()
|
||||
notification_chain_verbal = serializers.SerializerMethodField()
|
||||
cloud_connection_status = serializers.SerializerMethodField()
|
||||
working_hours = WorkingHoursSerializer(required=False)
|
||||
|
|
@ -96,6 +96,7 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
|
|||
"mobileappauthtoken",
|
||||
"google_oauth2_user",
|
||||
]
|
||||
PREFETCH_RELATED = ["notification_policies"]
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
|
|
@ -165,6 +166,10 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
|
|||
default, important = UserNotificationPolicy.get_short_verbals_for_user(user=obj)
|
||||
return {"default": " - ".join(default), "important": " - ".join(important)}
|
||||
|
||||
def get_avatar_full(self, obj):
|
||||
organization = self.context["request"].auth.organization if self.context.get("request") else obj.organization
|
||||
return obj.avatar_full_url(organization)
|
||||
|
||||
def get_cloud_connection_status(self, obj: User) -> CloudSyncStatus | None:
|
||||
is_open_source_with_cloud_notifications = self.context.get("is_open_source_with_cloud_notifications", None)
|
||||
is_open_source_with_cloud_notifications = (
|
||||
|
|
@ -307,7 +312,7 @@ class UserShortSerializer(serializers.ModelSerializer):
|
|||
username = serializers.CharField()
|
||||
pk = serializers.CharField(source="public_primary_key")
|
||||
avatar = serializers.CharField(source="avatar_url")
|
||||
avatar_full = serializers.CharField(source="avatar_full_url")
|
||||
avatar_full = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
|
|
@ -324,6 +329,10 @@ class UserShortSerializer(serializers.ModelSerializer):
|
|||
"avatar_full",
|
||||
]
|
||||
|
||||
def get_avatar_full(self, obj):
|
||||
organization = self.context["request"].auth.organization if self.context.get("request") else obj.organization
|
||||
return obj.avatar_full_url(organization)
|
||||
|
||||
|
||||
class UserIsCurrentlyOnCallSerializer(UserShortSerializer, EagerLoadingMixin):
|
||||
context: UserSerializerContext
|
||||
|
|
|
|||
|
|
@ -2083,7 +2083,7 @@ def test_alert_group_paged_users(
|
|||
assert response.json()["paged_users"] == [
|
||||
{
|
||||
"avatar": user2.avatar_url,
|
||||
"avatar_full": user2.avatar_full_url,
|
||||
"avatar_full": user2.avatar_full_url(user.organization),
|
||||
"id": user2.pk,
|
||||
"pk": user2.public_primary_key,
|
||||
"important": None,
|
||||
|
|
|
|||
|
|
@ -224,6 +224,41 @@ def test_direct_paging_no_user_or_team_specified(
|
|||
assert response.json()["detail"] == DirectPagingUserTeamValidationError.DETAIL
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_direct_paging_both_team_and_users_specified(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_user,
|
||||
make_team,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR)
|
||||
team = make_team(organization=organization)
|
||||
|
||||
# user must be part of the team
|
||||
user.teams.add(team)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:direct_paging")
|
||||
|
||||
response = client.post(
|
||||
url,
|
||||
data={
|
||||
"team": team.public_primary_key,
|
||||
"users": [
|
||||
{
|
||||
"id": make_user(organization=organization).public_primary_key,
|
||||
"important": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["non_field_errors"] == ["users and team are mutually exclusive"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field_name,field_value",
|
||||
[
|
||||
|
|
@ -1648,7 +1648,7 @@ def test_on_call_shift_preview(
|
|||
"display_name": other_user.username,
|
||||
"pk": other_user.public_primary_key,
|
||||
"email": other_user.email,
|
||||
"avatar_full": other_user.avatar_full_url,
|
||||
"avatar_full": other_user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"source": "web",
|
||||
|
|
@ -1978,7 +1978,7 @@ def test_on_call_shift_preview_update(
|
|||
"display_name": other_user.username,
|
||||
"pk": other_user.public_primary_key,
|
||||
"email": other_user.email,
|
||||
"avatar_full": other_user.avatar_full_url,
|
||||
"avatar_full": other_user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"source": "web",
|
||||
|
|
@ -2093,7 +2093,7 @@ def test_on_call_shift_preview_update_not_started_reuse_pk(
|
|||
"display_name": other_user.username,
|
||||
"pk": other_user.public_primary_key,
|
||||
"email": other_user.email,
|
||||
"avatar_full": other_user.avatar_full_url,
|
||||
"avatar_full": other_user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"source": "web",
|
||||
|
|
|
|||
|
|
@ -612,7 +612,7 @@ def test_get_detail_schedule_oncall_now_multipage_objects(
|
|||
"pk": user.public_primary_key,
|
||||
"username": user.username,
|
||||
"avatar": user.avatar_url,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
}
|
||||
],
|
||||
"has_gaps": False,
|
||||
|
|
@ -916,7 +916,7 @@ def test_events_calendar(
|
|||
"display_name": user.username,
|
||||
"pk": user.public_primary_key,
|
||||
"email": user.email,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"missing_users": [],
|
||||
|
|
@ -989,7 +989,7 @@ def test_filter_events_calendar(
|
|||
"display_name": user.username,
|
||||
"pk": user.public_primary_key,
|
||||
"email": user.email,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"missing_users": [],
|
||||
|
|
@ -1014,7 +1014,7 @@ def test_filter_events_calendar(
|
|||
"display_name": user.username,
|
||||
"pk": user.public_primary_key,
|
||||
"email": user.email,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
}
|
||||
],
|
||||
"missing_users": [],
|
||||
|
|
@ -1106,7 +1106,7 @@ def test_filter_events_range_calendar(
|
|||
"display_name": user.username,
|
||||
"pk": user.public_primary_key,
|
||||
"email": user.email,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"missing_users": [],
|
||||
|
|
@ -1197,7 +1197,7 @@ def test_filter_events_overrides(
|
|||
"display_name": other_user.username,
|
||||
"pk": other_user.public_primary_key,
|
||||
"email": other_user.email,
|
||||
"avatar_full": other_user.avatar_full_url,
|
||||
"avatar_full": other_user.avatar_full_url(organization),
|
||||
}
|
||||
],
|
||||
"missing_users": [],
|
||||
|
|
@ -1395,7 +1395,7 @@ def test_filter_swap_requests(
|
|||
"display_name": u.username,
|
||||
"email": u.email,
|
||||
"pk": u.public_primary_key,
|
||||
"avatar_full": u.avatar_full_url,
|
||||
"avatar_full": u.avatar_full_url(organization),
|
||||
}
|
||||
|
||||
expected = [
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ def _construct_serialized_object(
|
|||
"display_name": u.username,
|
||||
"email": u.email,
|
||||
"pk": u.public_primary_key,
|
||||
"avatar_full": u.avatar_full_url,
|
||||
"avatar_full": u.avatar_full_url(u.organization),
|
||||
}
|
||||
|
||||
data["beneficiary"] = _serialized_user(ssr.beneficiary)
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ def test_current_user(
|
|||
"notification_chain_verbal": {"default": "", "important": ""},
|
||||
"slack_user_identity": None,
|
||||
"avatar": user.avatar_url,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
"has_google_oauth2_connected": False,
|
||||
"google_calendar_settings": None,
|
||||
"google_oauth2_token_is_missing_scopes": False,
|
||||
|
|
@ -212,7 +212,7 @@ def test_update_user_cant_change_email_and_username(
|
|||
"notification_chain_verbal": {"default": "", "important": ""},
|
||||
"slack_user_identity": None,
|
||||
"avatar": admin.avatar_url,
|
||||
"avatar_full": admin.avatar_full_url,
|
||||
"avatar_full": admin.avatar_full_url(organization),
|
||||
"has_google_oauth2_connected": False,
|
||||
"google_calendar_settings": None,
|
||||
}
|
||||
|
|
@ -264,7 +264,7 @@ def test_list_users(
|
|||
"notification_chain_verbal": {"default": "", "important": ""},
|
||||
"slack_user_identity": None,
|
||||
"avatar": admin.avatar_url,
|
||||
"avatar_full": admin.avatar_full_url,
|
||||
"avatar_full": admin.avatar_full_url(organization),
|
||||
"cloud_connection_status": None,
|
||||
"has_google_oauth2_connected": False,
|
||||
},
|
||||
|
|
@ -290,7 +290,7 @@ def test_list_users(
|
|||
"notification_chain_verbal": {"default": "", "important": ""},
|
||||
"slack_user_identity": None,
|
||||
"avatar": editor.avatar_url,
|
||||
"avatar_full": editor.avatar_full_url,
|
||||
"avatar_full": editor.avatar_full_url(organization),
|
||||
"cloud_connection_status": None,
|
||||
"has_google_oauth2_connected": False,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from .views.alert_receive_channel import AlertReceiveChannelView
|
|||
from .views.alert_receive_channel_template import AlertReceiveChannelTemplateView
|
||||
from .views.alerts import AlertDetailView
|
||||
from .views.channel_filter import ChannelFilterView
|
||||
from .views.direct_paging import DirectPagingAPIView
|
||||
from .views.escalation_chain import EscalationChainViewSet
|
||||
from .views.escalation_policy import EscalationPolicyView
|
||||
from .views.features import FeaturesAPIView
|
||||
|
|
@ -23,7 +24,6 @@ from .views.organization import (
|
|||
OrganizationConfigChecksView,
|
||||
SetGeneralChannel,
|
||||
)
|
||||
from .views.paging import DirectPagingAPIView
|
||||
from .views.preview_template_options import PreviewTemplateOptionsView
|
||||
from .views.public_api_tokens import PublicApiTokenView
|
||||
from .views.resolution_note import ResolutionNoteView
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from rest_framework.views import APIView
|
|||
|
||||
from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError, direct_paging
|
||||
from apps.api.permissions import RBACPermission
|
||||
from apps.api.serializers.paging import DirectPagingSerializer
|
||||
from apps.api.serializers.direct_paging import DirectPagingSerializer
|
||||
from apps.auth_token.auth import PluginAuthentication
|
||||
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
|
|
@ -385,7 +385,9 @@ class ScheduleView(
|
|||
|
||||
swap_requests = schedule.filter_swap_requests(datetime_start, datetime_end)
|
||||
|
||||
serialized_swap_requests = ShiftSwapRequestExpandedUsersListSerializer(swap_requests, many=True)
|
||||
serialized_swap_requests = ShiftSwapRequestExpandedUsersListSerializer(
|
||||
swap_requests, context={"request": self.request}, many=True
|
||||
)
|
||||
result = {"shift_swaps": serialized_swap_requests.data}
|
||||
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from django.conf import settings
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.base.messaging import get_messaging_backends
|
||||
from apps.user_management.models import User
|
||||
|
|
@ -128,13 +127,18 @@ class UserNotificationPolicy(OrderedModel):
|
|||
|
||||
@classmethod
|
||||
def get_short_verbals_for_user(cls, user: User) -> Tuple[Tuple[str, ...], Tuple[str, ...]]:
|
||||
is_wait_step = Q(step=cls.Step.WAIT)
|
||||
is_wait_step_configured = Q(wait_delay__isnull=False)
|
||||
policies = user.notification_policies.all()
|
||||
|
||||
policies = cls.objects.filter(Q(user=user, step__isnull=False) & (~is_wait_step | is_wait_step_configured))
|
||||
default = ()
|
||||
important = ()
|
||||
|
||||
default = tuple(str(policy.short_verbal) for policy in policies if policy.important is False)
|
||||
important = tuple(str(policy.short_verbal) for policy in policies if policy.important is True)
|
||||
for policy in policies:
|
||||
if policy.step is None or (policy.step == cls.Step.WAIT and policy.wait_delay is None):
|
||||
continue
|
||||
if policy.important:
|
||||
important += (policy.short_verbal,)
|
||||
else:
|
||||
default += (policy.short_verbal,)
|
||||
|
||||
return default, important
|
||||
|
||||
|
|
|
|||
|
|
@ -171,9 +171,10 @@ class UserNotificationPolicyLogRecord(models.Model):
|
|||
def rendered_notification_log_line_json(self):
|
||||
time = humanize.naturaldelta(self.alert_group.started_at - self.created_at)
|
||||
created_at = DateTimeField().to_representation(self.created_at)
|
||||
author = self.author.short() if self.author is not None else None
|
||||
organization = self.alert_group.channel.organization
|
||||
author = self.author.short(organization) if self.author is not None else None
|
||||
|
||||
sf = SlackFormatter(self.alert_group.channel.organization)
|
||||
sf = SlackFormatter(organization)
|
||||
action = sf.format(self.render_log_line_action(substitute_author_with_tag=True))
|
||||
action = clean_markup(action)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from .alerts import AlertSerializer # noqa: F401
|
||||
from .escalation import EscalationSerializer # noqa: F401
|
||||
from .escalation_chains import EscalationChainSerializer # noqa: F401
|
||||
from .escalation_policies import EscalationPolicySerializer, EscalationPolicyUpdateSerializer # noqa: F401
|
||||
from .incidents import IncidentSerializer # noqa: F401
|
||||
|
|
|
|||
8
engine/apps/public_api/serializers/escalation.py
Normal file
8
engine/apps/public_api/serializers/escalation.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from apps.api.serializers.direct_paging import BasePagingSerializer
|
||||
|
||||
|
||||
class EscalationSerializer(BasePagingSerializer):
|
||||
"""
|
||||
Very similar to `apps.api.serializers.direct_paging.DirectPagingSerializer` except that
|
||||
there is no `grafana_incident_id` attribute
|
||||
"""
|
||||
294
engine/apps/public_api/tests/test_escalation.py
Normal file
294
engine/apps/public_api/tests/test_escalation.py
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.alerts.models import AlertGroup
|
||||
from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError
|
||||
|
||||
title = "Custom title"
|
||||
message = "Testing escalation with new alert group"
|
||||
source_url = "https://www.example.com"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_escalation_new_alert_group(
|
||||
make_organization_and_user_with_token,
|
||||
make_user,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
|
||||
users_to_page = [
|
||||
{
|
||||
"id": make_user(organization=organization).public_primary_key,
|
||||
"important": False,
|
||||
},
|
||||
{
|
||||
"id": make_user(organization=organization).public_primary_key,
|
||||
"important": True,
|
||||
},
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-public:escalation")
|
||||
|
||||
response = client.post(
|
||||
url,
|
||||
data={
|
||||
"users": users_to_page,
|
||||
"title": title,
|
||||
"message": message,
|
||||
},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
alert_groups = AlertGroup.objects.all()
|
||||
assert alert_groups.count() == 1
|
||||
ag = alert_groups.get()
|
||||
|
||||
assert response.json() == {
|
||||
"id": ag.public_primary_key,
|
||||
"integration_id": ag.channel.public_primary_key,
|
||||
"route_id": ag.channel_filter.public_primary_key,
|
||||
"alerts_count": 1,
|
||||
"state": "firing",
|
||||
"created_at": mock.ANY,
|
||||
"resolved_at": None,
|
||||
"resolved_by": None,
|
||||
"acknowledged_at": None,
|
||||
"acknowledged_by": None,
|
||||
"title": title,
|
||||
"permalinks": {
|
||||
"slack": None,
|
||||
"slack_app": None,
|
||||
"telegram": None,
|
||||
"web": f"a/grafana-oncall-app/alert-groups/{ag.public_primary_key}",
|
||||
},
|
||||
"silenced_at": None,
|
||||
}
|
||||
|
||||
alert = ag.alerts.get()
|
||||
|
||||
assert ag.web_title_cache == title
|
||||
assert alert.title == title
|
||||
assert alert.message == message
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_escalation_team(
|
||||
make_organization_and_user_with_token,
|
||||
make_team,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
team = make_team(organization=organization)
|
||||
|
||||
# user must be part of the team
|
||||
user.teams.add(team)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-public:escalation")
|
||||
|
||||
response = client.post(
|
||||
url,
|
||||
data={
|
||||
"team": team.public_primary_key,
|
||||
"message": message,
|
||||
"source_url": source_url,
|
||||
},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
alert_group = AlertGroup.objects.get(public_primary_key=response.json()["id"])
|
||||
alert = alert_group.alerts.first()
|
||||
|
||||
assert alert.raw_request_data["oncall"]["permalink"] == source_url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_escalation_existing_alert_group(
|
||||
make_organization_and_user_with_token,
|
||||
make_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
|
||||
users_to_page = [
|
||||
{
|
||||
"id": make_user(organization=organization).public_primary_key,
|
||||
"important": False,
|
||||
},
|
||||
{
|
||||
"id": make_user(
|
||||
organization=organization,
|
||||
).public_primary_key,
|
||||
"important": True,
|
||||
},
|
||||
]
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-public:escalation")
|
||||
|
||||
response = client.post(
|
||||
url,
|
||||
data={"users": users_to_page, "alert_group_id": alert_group.public_primary_key},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["id"] == alert_group.public_primary_key
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_escalation_existing_alert_group_resolved(
|
||||
make_organization_and_user_with_token,
|
||||
make_user,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel, resolved=True)
|
||||
|
||||
users_to_page = [
|
||||
{
|
||||
"id": make_user(organization=organization).public_primary_key,
|
||||
"important": False,
|
||||
},
|
||||
]
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-public:escalation")
|
||||
|
||||
response = client.post(
|
||||
url,
|
||||
data={
|
||||
"alert_group_id": alert_group.public_primary_key,
|
||||
"users": users_to_page,
|
||||
},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["detail"] == DirectPagingAlertGroupResolvedError.DETAIL
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_escalation_no_user_or_team_specified(
|
||||
make_organization_and_user_with_token,
|
||||
make_user_auth_headers,
|
||||
):
|
||||
_, user, token = make_organization_and_user_with_token()
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-public:escalation")
|
||||
|
||||
response = client.post(
|
||||
url,
|
||||
data={
|
||||
"team": None,
|
||||
"users": [],
|
||||
},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["detail"] == DirectPagingUserTeamValidationError.DETAIL
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_escalation_both_team_and_users_specified(
|
||||
make_organization_and_user_with_token,
|
||||
make_user_auth_headers,
|
||||
make_user,
|
||||
make_team,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
team = make_team(organization=organization)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-public:escalation")
|
||||
|
||||
response = client.post(
|
||||
url,
|
||||
data={
|
||||
"team": team.public_primary_key,
|
||||
"users": [
|
||||
{
|
||||
"id": make_user(organization=organization).public_primary_key,
|
||||
"important": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["non_field_errors"] == ["users and team are mutually exclusive"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field_name,field_value",
|
||||
[
|
||||
("title", title),
|
||||
("message", message),
|
||||
("source_url", source_url),
|
||||
],
|
||||
)
|
||||
@pytest.mark.django_db
|
||||
def test_escalation_alert_group_id_and_other_fields_are_mutually_exclusive(
|
||||
make_organization_and_user_with_token,
|
||||
make_team,
|
||||
make_user_auth_headers,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
field_name,
|
||||
field_value,
|
||||
):
|
||||
error_msg = "alert_group_id and (title, message, source_url) are mutually exclusive"
|
||||
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
team = make_team(organization=organization)
|
||||
|
||||
# user must be part of the team
|
||||
user.teams.add(team)
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel, resolved=True)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-public:escalation")
|
||||
|
||||
response = client.post(
|
||||
url,
|
||||
data={
|
||||
"team": team.public_primary_key,
|
||||
"alert_group_id": alert_group.public_primary_key,
|
||||
field_name: field_value,
|
||||
},
|
||||
format="json",
|
||||
**make_user_auth_headers(user, token),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["non_field_errors"] == [error_msg]
|
||||
|
|
@ -34,4 +34,5 @@ urlpatterns = [
|
|||
optional_slash_path("info", views.InfoView.as_view(), name="info"),
|
||||
optional_slash_path("make_call", views.MakeCallView.as_view(), name="make_call"),
|
||||
optional_slash_path("send_sms", views.SendSMSView.as_view(), name="send_sms"),
|
||||
optional_slash_path("escalation", views.EscalationView.as_view(), name="escalation"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from .action import ActionView # noqa: F401
|
||||
from .alerts import AlertView # noqa: F401
|
||||
from .escalation import EscalationView # noqa: F401
|
||||
from .escalation_chains import EscalationChainView # noqa: F401
|
||||
from .escalation_policies import EscalationPolicyView # noqa: F401
|
||||
from .incidents import IncidentView # noqa: F401
|
||||
|
|
|
|||
46
engine/apps/public_api/views/escalation.py
Normal file
46
engine/apps/public_api/views/escalation.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from rest_framework import status
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.alerts.paging import DirectPagingAlertGroupResolvedError, DirectPagingUserTeamValidationError, direct_paging
|
||||
from apps.auth_token.auth import ApiTokenAuthentication
|
||||
from apps.public_api.serializers import EscalationSerializer, IncidentSerializer
|
||||
from apps.public_api.throttlers import UserThrottle
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
|
||||
|
||||
class EscalationView(APIView):
|
||||
"""
|
||||
aka "Direct Paging"
|
||||
"""
|
||||
|
||||
authentication_classes = (ApiTokenAuthentication,)
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
throttle_classes = [UserThrottle]
|
||||
|
||||
def post(self, request):
|
||||
user = request.user
|
||||
organization = user.organization
|
||||
|
||||
serializer = EscalationSerializer(data=request.data, context={"organization": organization, "request": request})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
validated_data = serializer.validated_data
|
||||
|
||||
try:
|
||||
alert_group = direct_paging(
|
||||
organization=organization,
|
||||
from_user=user,
|
||||
message=validated_data["message"],
|
||||
title=validated_data["title"],
|
||||
source_url=validated_data["source_url"],
|
||||
team=validated_data["team"],
|
||||
users=[(user["instance"], user["important"]) for user in validated_data["users"]],
|
||||
alert_group=validated_data["alert_group"],
|
||||
)
|
||||
except DirectPagingAlertGroupResolvedError:
|
||||
raise BadRequest(detail=DirectPagingAlertGroupResolvedError.DETAIL)
|
||||
except DirectPagingUserTeamValidationError:
|
||||
raise BadRequest(detail=DirectPagingUserTeamValidationError.DETAIL)
|
||||
return Response(IncidentSerializer(alert_group).data, status=status.HTTP_200_OK)
|
||||
|
|
@ -407,7 +407,7 @@ class OnCallSchedule(PolymorphicModel):
|
|||
"display_name": user.username,
|
||||
"email": user.email,
|
||||
"pk": user.public_primary_key,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(self.organization),
|
||||
}
|
||||
for user in shift["users"]
|
||||
],
|
||||
|
|
@ -780,13 +780,13 @@ class OnCallSchedule(PolymorphicModel):
|
|||
user_to_swap["pk"] = swap.benefactor.public_primary_key
|
||||
user_to_swap["display_name"] = swap.benefactor.username
|
||||
user_to_swap["email"] = swap.benefactor.email
|
||||
user_to_swap["avatar_full"] = swap.benefactor.avatar_full_url
|
||||
user_to_swap["avatar_full"] = swap.benefactor.avatar_full_url(self.organization)
|
||||
# add beneficiary user to details
|
||||
swap_details["user"] = {
|
||||
"display_name": swap.beneficiary.username,
|
||||
"email": swap.beneficiary.email,
|
||||
"pk": swap.beneficiary.public_primary_key,
|
||||
"avatar_full": swap.beneficiary.avatar_full_url,
|
||||
"avatar_full": swap.beneficiary.avatar_full_url(self.organization),
|
||||
}
|
||||
user_to_swap["swap_request"] = swap_details
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ def test_filter_events(make_organization, make_user_for_organization, make_sched
|
|||
"display_name": user.username,
|
||||
"pk": user.public_primary_key,
|
||||
"email": user.email,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"shift": {"pk": on_call_shift.public_primary_key},
|
||||
|
|
@ -127,7 +127,7 @@ def test_filter_events(make_organization, make_user_for_organization, make_sched
|
|||
"display_name": user.username,
|
||||
"pk": user.public_primary_key,
|
||||
"email": user.email,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"shift": {"pk": override.public_primary_key},
|
||||
|
|
@ -199,7 +199,7 @@ def test_filter_events_include_gaps(make_organization, make_user_for_organizatio
|
|||
"display_name": user.username,
|
||||
"pk": user.public_primary_key,
|
||||
"email": user.email,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"shift": {"pk": on_call_shift.public_primary_key},
|
||||
|
|
@ -284,7 +284,7 @@ def test_filter_events_include_shift_info(
|
|||
"display_name": user.username,
|
||||
"pk": user.public_primary_key,
|
||||
"email": user.email,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"shift": {
|
||||
|
|
@ -899,7 +899,7 @@ def test_preview_shift(make_organization, make_user_for_organization, make_sched
|
|||
"display_name": other_user.username,
|
||||
"pk": other_user.public_primary_key,
|
||||
"email": other_user.email,
|
||||
"avatar_full": other_user.avatar_full_url,
|
||||
"avatar_full": other_user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"shift": {"pk": new_shift.public_primary_key},
|
||||
|
|
@ -1001,7 +1001,7 @@ def test_preview_shift_do_not_change_rotation_events(
|
|||
"display_name": user.username,
|
||||
"pk": user.public_primary_key,
|
||||
"email": user.email,
|
||||
"avatar_full": user.avatar_full_url,
|
||||
"avatar_full": user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"shift": {"pk": on_call_shift.public_primary_key},
|
||||
|
|
@ -1137,7 +1137,7 @@ def test_preview_override_shift(make_organization, make_user_for_organization, m
|
|||
"display_name": other_user.username,
|
||||
"pk": other_user.public_primary_key,
|
||||
"email": other_user.email,
|
||||
"avatar_full": other_user.avatar_full_url,
|
||||
"avatar_full": other_user.avatar_full_url(organization),
|
||||
},
|
||||
],
|
||||
"shift": {"pk": new_shift.public_primary_key},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import enum
|
||||
import json
|
||||
import logging
|
||||
import typing
|
||||
from urllib.parse import urljoin
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
|
|
@ -13,7 +15,7 @@ from apps.api.permissions import RBACPermission, user_is_authorized
|
|||
from apps.schedules.ical_utils import get_cached_oncall_users_for_multiple_schedules
|
||||
from apps.slack.chatops_proxy_routing import make_private_metadata, make_value
|
||||
from apps.slack.constants import DIVIDER, PRIVATE_METADATA_MAX_LENGTH
|
||||
from apps.slack.errors import SlackAPIChannelNotFoundError
|
||||
from apps.slack.errors import SlackAPIChannelNotFoundError, SlackAPIError
|
||||
from apps.slack.scenarios import scenario_step
|
||||
from apps.slack.slash_command import SlashCommand
|
||||
from apps.slack.types import (
|
||||
|
|
@ -27,6 +29,8 @@ from apps.slack.types import (
|
|||
ScenarioRoute,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from django.db.models.manager import RelatedManager
|
||||
|
||||
|
|
@ -138,6 +142,35 @@ class StartDirectPaging(scenario_step.ScenarioStep):
|
|||
except KeyError:
|
||||
channel_id = payload["channel_id"]
|
||||
|
||||
if settings.UNIFIED_SLACK_APP_ENABLED:
|
||||
if slack_team_identity.needs_reinstall:
|
||||
organizations = _get_available_organizations(slack_team_identity, slack_user_identity)
|
||||
# Provide a link to web if user has access only to one organization
|
||||
if len(organizations) == 1:
|
||||
link = urljoin(organizations[0].web_link, "settings?tab=ChatOps&chatOpsTab=Slack")
|
||||
upgrade = f"<{link}|Upgrade>"
|
||||
else:
|
||||
upgrade = "Upgrade" # TODO: Add link to docs are available
|
||||
msg = (
|
||||
f"The new Slack IRM integration is now available. f{upgrade} for a more powerful and flexible "
|
||||
f"way to interact with Grafana IRM on Slack."
|
||||
)
|
||||
try:
|
||||
self._slack_client.chat_postEphemeral(
|
||||
channel=channel_id, user=slack_user_identity.slack_id, text=msg
|
||||
)
|
||||
except SlackAPIError:
|
||||
# catch all exceptions to prevent the slash command from failing
|
||||
logger.warning("StartDirectPaging: failed to send ephemeral message to user", exc_info=True)
|
||||
else:
|
||||
self._slack_client.chat_postEphemeral(
|
||||
channel=channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
text="The new Slack IRM integration is now available. Please use /grafana-irm escalate to "
|
||||
"complete the action",
|
||||
)
|
||||
return
|
||||
|
||||
private_metadata = {
|
||||
"channel_id": channel_id,
|
||||
"input_id_prefix": input_id_prefix,
|
||||
|
|
|
|||
|
|
@ -195,9 +195,12 @@ class User(models.Model):
|
|||
return False
|
||||
return not google_utils.user_granted_all_required_scopes(self.google_oauth2_user.oauth_scope)
|
||||
|
||||
@property
|
||||
def avatar_full_url(self):
|
||||
return urljoin(self.organization.grafana_url, self.avatar_url)
|
||||
def avatar_full_url(self, organization: "Organization"):
|
||||
"""
|
||||
Use arg `organization` instead of `self.organization` to avoid multiple requests to db when getting avatar for
|
||||
users list
|
||||
"""
|
||||
return urljoin(organization.grafana_url, self.avatar_url)
|
||||
|
||||
@property
|
||||
def verified_phone_number(self) -> str | None:
|
||||
|
|
@ -295,12 +298,12 @@ class User(models.Model):
|
|||
|
||||
return day_start <= dt <= day_end
|
||||
|
||||
def short(self):
|
||||
def short(self, organization):
|
||||
return {
|
||||
"username": self.username,
|
||||
"pk": self.public_primary_key,
|
||||
"avatar": self.avatar_url,
|
||||
"avatar_full": self.avatar_full_url,
|
||||
"avatar_full": self.avatar_full_url(organization),
|
||||
}
|
||||
|
||||
# Insight logs
|
||||
|
|
|
|||
|
|
@ -113,14 +113,14 @@ def test_sync_users_for_organization(make_organization, make_user_for_organizati
|
|||
assert updated_user is not None
|
||||
assert updated_user.name == api_users[0]["name"]
|
||||
assert updated_user.email == api_users[0]["email"]
|
||||
assert updated_user.avatar_full_url == "https://test.test/test/1234"
|
||||
assert updated_user.avatar_full_url(organization) == "https://test.test/test/1234"
|
||||
|
||||
# check that missing users are created
|
||||
created_user = organization.users.filter(user_id=api_users[1]["userId"]).first()
|
||||
assert created_user is not None
|
||||
assert created_user.user_id == api_users[1]["userId"]
|
||||
assert created_user.name == api_users[1]["name"]
|
||||
assert created_user.avatar_full_url == "https://test.test/test/1234"
|
||||
assert created_user.avatar_full_url(organization) == "https://test.test/test/1234"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -532,7 +532,7 @@ def test_get_or_create_user(make_organization, make_team, make_user_for_organiza
|
|||
assert user.user_id == sync_user.id
|
||||
assert user.name == sync_user.name
|
||||
assert user.email == sync_user.email
|
||||
assert user.avatar_full_url == sync_user.avatar_url
|
||||
assert user.avatar_full_url(organization) == sync_user.avatar_url
|
||||
assert organization.users.count() == 2
|
||||
assert team.users.count() == 1
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue