Updates to POST /direct_paging internal API endpoint to support Grafana Incident use-cases (#3232)

# What this PR does

- Add a new column, `grafana_incident_id`, to the `AlertGroup` model.
For now this is really just needed to determine if the Alert Group was
created, via a Direct Page, that originated from Grafana Incident.
- I understand that these IDs may be cluster specific. For now we will
not need to make OnCall backend -> Incident API calls. Should we need to
start doing this we will likely need to start syncing the Incident
plugin's provisioned API url into the `organization` in OnCall, such
that we make the API call to the right Incident backend.
- Add two new optional request body parameters to `POST /direct_paging`,
`source_url` and `grafana_incident_id`
- `source_url` - will easily allow Grafana Incident to specify the URL
to the Incident and have this populate the "Source" button
- `grafana_incident_id` - Grafana Incident can specify this such that we
have a link back to Incident (+ we know that the Alert Group was
generated from Incident)
- Hide the "Declare Incident" button in the UI if the Alert Group was
generated from Grafana Incident

## Checklist

- [ ] Unit, integration, and e2e (if applicable) tests updated
- [ ] Documentation added (or `pr:no public docs` PR label added if not
required)
- [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Joey Orlando 2023-11-01 17:19:44 -04:00 committed by GitHub
parent 3b02c587f5
commit f905ac5246
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 128 additions and 32 deletions

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.6 on 2023-10-31 20:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0035_alter_alertreceivechannel_maintenance_author'),
]
operations = [
migrations.AddField(
model_name='alertgroup',
name='grafana_incident_id',
field=models.CharField(default=None, max_length=100, null=True),
),
]

View file

@ -392,6 +392,8 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
# TODO: drop this column in an upcoming release
is_restricted = models.BooleanField(default=False, null=True)
grafana_incident_id = models.CharField(max_length=100, null=True, default=None)
@staticmethod
def get_silenced_state_filter():
"""

View file

@ -44,7 +44,13 @@ class DirectPagingAlertPayload(typing.TypedDict):
def _trigger_alert(
organization: Organization, team: Team | None, message: str, title: str, from_user: User
organization: Organization,
team: Team | None,
message: str,
title: str,
permalink: str | None,
grafana_incident_id: str | None,
from_user: User,
) -> AlertGroup:
"""Trigger manual integration alert from params."""
alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration(
@ -73,7 +79,7 @@ def _trigger_alert(
"message": message,
"uid": str(uuid4()), # avoid grouping
"author_username": from_user.username,
"permalink": None,
"permalink": permalink,
},
}
@ -87,7 +93,13 @@ def _trigger_alert(
link_to_upstream_details=None,
channel_filter=channel_filter,
)
return alert.group
alert_group = alert.group
if grafana_incident_id is not None:
alert_group.grafana_incident_id = grafana_incident_id
alert_group.save(update_fields=["grafana_incident_id"])
return alert_group
def _construct_title(from_user: User, team: Team | None, users: UserNotifications) -> str:
@ -111,6 +123,8 @@ def direct_paging(
from_user: User,
message: str,
title: str | None = None,
source_url: str | None = None,
grafana_incident_id: str | None = None,
team: Team | None = None,
users: UserNotifications | None = None,
alert_group: AlertGroup | None = None,
@ -135,7 +149,7 @@ def direct_paging(
# create alert group if needed
if alert_group is None:
alert_group = _trigger_alert(organization, team, message, title, from_user)
alert_group = _trigger_alert(organization, team, message, title, source_url, grafana_incident_id, from_user)
for u, important in users:
alert_group.log_records.create(

View file

@ -149,6 +149,7 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize
"status",
"declare_incident_link",
"team",
"grafana_incident_id",
]
@extend_schema_field(

View file

@ -43,15 +43,21 @@ 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"]
alert_group_id = attrs["alert_group_id"]
title = attrs["title"]
message = attrs["message"]
source_url = attrs["source_url"]
grafana_incident_id = attrs["grafana_incident_id"]
if alert_group_id and (title or message):
raise serializers.ValidationError("alert_group_id and (title, message) are mutually exclusive")
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"
)
if alert_group_id:
try:

View file

@ -9,6 +9,8 @@ from apps.api.permissions import LegacyAccessControlRole
title = "Custom title"
message = "Testing direct paging with new alert group"
source_url = "https://www.example.com"
grafana_incident_id = "abcd1234"
@pytest.mark.django_db
@ -77,6 +79,43 @@ def test_direct_paging_page_team(
data={
"team": team.public_primary_key,
"message": message,
"source_url": source_url,
"grafana_incident_id": grafana_incident_id,
},
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()["alert_group_id"])
alert = alert_group.alerts.first()
assert alert_group.grafana_incident_id == grafana_incident_id
assert alert.raw_request_data["oncall"]["permalink"] == source_url
@pytest.mark.django_db
def test_direct_paging_page_from_grafana_incident(
make_organization_and_user_with_plugin_token,
make_team,
make_user_auth_headers,
):
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,
"message": message,
"grafana_incident_id": "asdf",
},
format="json",
**make_user_auth_headers(user, token),
@ -185,15 +224,26 @@ def test_direct_paging_no_user_or_team_specified(
assert response.json()["detail"] == DirectPagingUserTeamValidationError.DETAIL
@pytest.mark.parametrize(
"field_name,field_value",
[
("title", title),
("message", message),
("source_url", source_url),
("grafana_incident_id", grafana_incident_id),
],
)
@pytest.mark.django_db
def test_direct_paging_alert_group_id_and_message_or_title_are_mutually_exclusive(
def test_direct_paging_alert_group_id_and_other_fields_are_mutually_exclusive(
make_organization_and_user_with_plugin_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) are mutually exclusive"
error_msg = "alert_group_id and (title, message, source_url, grafana_incident_id) are mutually exclusive"
organization, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.EDITOR)
team = make_team(organization=organization)
@ -207,17 +257,15 @@ def test_direct_paging_alert_group_id_and_message_or_title_are_mutually_exclusiv
client = APIClient()
url = reverse("api-internal:direct_paging")
base_data = {"team": team.public_primary_key, "alert_group_id": alert_group.public_primary_key}
response = client.post(
url, data={**base_data, "message": message}, 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]
response = client.post(
url, data={**base_data, "title": title}, format="json", **make_user_auth_headers(user, token)
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

View file

@ -33,6 +33,8 @@ class DirectPagingAPIView(APIView):
from_user=request.user,
message=validated_data["message"],
title=validated_data["title"],
source_url=validated_data["source_url"],
grafana_incident_id=validated_data["grafana_incident_id"],
team=validated_data["team"],
users=[(user["instance"], user["important"]) for user in validated_data["users"]],
alert_group=validated_data["alert_group"],

View file

@ -85,6 +85,7 @@ export interface Alert {
alert_receive_channel: Partial<AlertReceiveChannel>;
paged_users: PagedUser[];
team: GrafanaTeam['id'];
grafana_incident_id: string | null;
// set by client
loading?: boolean;

View file

@ -177,13 +177,15 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
</div>
<div className={cx('column')}>
<VerticalGroup style={{ display: 'block' }}>
<AddResponders
mode="update"
hideAddResponderButton={incident.resolved}
existingPagedUsers={incident.paged_users}
onAddNewParticipant={this.handleAddUserResponder}
generateRemovePreviouslyPagedUserCallback={this.handlePagedUserRemove}
/>
{(!incident.resolved || incident.paged_users.length > 0) && (
<AddResponders
mode="update"
hideAddResponderButton={incident.resolved}
existingPagedUsers={incident.paged_users}
onAddNewParticipant={this.handleAddUserResponder}
generateRemovePreviouslyPagedUserCallback={this.handlePagedUserRemove}
/>
)}
{this.renderTimeline()}
</VerticalGroup>
</div>
@ -400,13 +402,15 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
onSilence: this.getSilenceClickHandler(incident),
onUnsilence: this.getUnsilenceClickHandler(incident),
})}
<PluginBridge plugin={SupportedPlugin.Incident}>
<a href={incident.declare_incident_link} target="_blank" rel="noreferrer">
<Button variant="secondary" size="md" icon="fire">
Declare incident
</Button>
</a>
</PluginBridge>
{incident.grafana_incident_id === null && (
<PluginBridge plugin={SupportedPlugin.Incident}>
<a href={incident.declare_incident_link} target="_blank" rel="noreferrer">
<Button variant="secondary" size="md" icon="fire">
Declare incident
</Button>
</a>
</PluginBridge>
)}
</HorizontalGroup>
<Button