This commit is contained in:
Joey Orlando 2024-08-15 14:48:55 -04:00 committed by GitHub
commit d8b4d10406
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 867 additions and 62 deletions

View 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/`

View 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/`

View 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/`

View file

@ -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()],
}

View file

@ -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)

View file

@ -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)

View file

@ -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():

View file

@ -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):

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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",
[

View file

@ -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",

View file

@ -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 = [

View file

@ -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)

View file

@ -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,
},

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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

View 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
"""

View 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]

View file

@ -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"),
]

View file

@ -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

View 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)

View file

@ -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

View file

@ -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},

View file

@ -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,

View file

@ -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

View file

@ -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