Resolution note source mobile app (#3174)

# What this PR does

Fixes https://github.com/grafana/oncall/issues/2320

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)
This commit is contained in:
Vadim Stepanov 2023-10-20 15:22:45 +01:00 committed by GitHub
parent bf197b09c2
commit 2179e7a1c9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 130 additions and 10 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Discard old pending network requests in the UI (Users/Schedules) [#3172](https://github.com/grafana/oncall/pull/3172) - Discard old pending network requests in the UI (Users/Schedules) [#3172](https://github.com/grafana/oncall/pull/3172)
- Fix resolution note source for mobile app by @vadimkerr ([#3174](https://github.com/grafana/oncall/pull/3174))
## v1.3.45 (2023-10-19) ## v1.3.45 (2023-10-19)

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.20 on 2023-10-20 13:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('alerts', '0033_alertgrouplogrecord_action_source'),
]
operations = [
migrations.AlterField(
model_name='resolutionnote',
name='source',
field=models.IntegerField(choices=[(0, 'Slack'), (1, 'Web'), (2, 'Mobile App')], default=None, null=True),
),
]

View file

@ -120,8 +120,9 @@ class ResolutionNote(models.Model):
objects_with_deleted = models.Manager() objects_with_deleted = models.Manager()
class Source(models.IntegerChoices): class Source(models.IntegerChoices):
SLACK = 0, "slack" SLACK = 0, "Slack"
WEB = 1, "web" WEB = 1, "Web"
MOBILE_APP = 2, "Mobile App"
public_primary_key = models.CharField( public_primary_key = models.CharField(
max_length=20, max_length=20,

View file

@ -2,6 +2,7 @@ from rest_framework import serializers
from apps.alerts.models import AlertGroup, ResolutionNote from apps.alerts.models import AlertGroup, ResolutionNote
from apps.api.serializers.user import FastUserSerializer from apps.api.serializers.user import FastUserSerializer
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField
from common.api_helpers.exceptions import BadRequest from common.api_helpers.exceptions import BadRequest
from common.api_helpers.mixins import EagerLoadingMixin from common.api_helpers.mixins import EagerLoadingMixin
@ -36,7 +37,13 @@ class ResolutionNoteSerializer(EagerLoadingMixin, serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
validated_data["author"] = self.context["request"].user validated_data["author"] = self.context["request"].user
validated_data["source"] = ResolutionNote.Source.WEB
if isinstance(self.context["request"].successful_authenticator, MobileAppAuthTokenAuthentication):
source = ResolutionNote.Source.MOBILE_APP
else:
source = ResolutionNote.Source.WEB
validated_data["source"] = source
created_instance = super().create(validated_data) created_instance = super().create(validated_data)
return created_instance return created_instance

View file

@ -10,7 +10,7 @@ from rest_framework.response import Response
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.alerts.constants import ActionSource from apps.alerts.constants import ActionSource
from apps.alerts.models import AlertGroup, AlertGroupLogRecord from apps.alerts.models import AlertGroup, AlertGroupLogRecord, ResolutionNote
from apps.alerts.tasks import wipe from apps.alerts.tasks import wipe
from apps.api.errors import AlertGroupAPIError from apps.api.errors import AlertGroupAPIError
from apps.api.permissions import LegacyAccessControlRole from apps.api.permissions import LegacyAccessControlRole
@ -1883,6 +1883,68 @@ def test_alert_group_resolve_resolution_note(
assert mock_signal.called assert mock_signal.called
@pytest.mark.django_db
def test_alert_group_resolve_resolution_note_mobile_app(
make_organization_and_user,
make_mobile_app_auth_token_for_user,
make_alert_receive_channel,
make_channel_filter,
make_alert_group,
make_alert,
make_user_auth_headers,
):
organization, user = make_organization_and_user()
organization.is_resolution_note_required = True
organization.save()
_, token = make_mobile_app_auth_token_for_user(user, organization)
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
client = APIClient()
url = reverse("api-internal:alertgroup-resolve", kwargs={"pk": alert_group.public_primary_key})
response = client.post(url, format="json", data={"resolution_note": "hi"}, HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_200_OK
assert alert_group.resolution_notes.get().source == ResolutionNote.Source.MOBILE_APP
@pytest.mark.parametrize("source", ResolutionNote.Source)
@pytest.mark.django_db
def test_timeline_resolution_note_source(
make_organization_and_user_with_plugin_token,
make_alert_receive_channel,
make_channel_filter,
make_alert_group,
make_alert,
make_resolution_note_slack_message,
make_resolution_note,
make_user_auth_headers,
source,
):
"""The 'type' field in timeline items should hold the source of the resolution note"""
organization, user, token = make_organization_and_user_with_plugin_token()
alert_receive_channel = make_alert_receive_channel(organization)
channel_filter = make_channel_filter(alert_receive_channel, is_default=True)
alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter)
make_alert(alert_group=alert_group, raw_request_data=alert_raw_request_data)
# Create resolution note
resolution_note_slack_message = make_resolution_note_slack_message(
alert_group=alert_group, user=user, added_by_user=user, text="resolution note"
)
make_resolution_note(
alert_group=alert_group, author=user, resolution_note_slack_message=resolution_note_slack_message, source=source
)
client = APIClient()
url = reverse("api-internal:alertgroup-detail", kwargs={"pk": alert_group.public_primary_key})
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["render_after_resolve_report_json"][0]["type"] == source.value
@pytest.mark.django_db @pytest.mark.django_db
def test_timeline_api_action( def test_timeline_api_action(
make_organization_and_user_with_plugin_token, make_organization_and_user_with_plugin_token,

View file

@ -35,8 +35,8 @@ def test_create_resolution_note(
"id": resolution_note.public_primary_key, "id": resolution_note.public_primary_key,
"alert_group": alert_group.public_primary_key, "alert_group": alert_group.public_primary_key,
"source": { "source": {
"id": resolution_note.source, "id": ResolutionNote.Source.WEB.value,
"display_name": resolution_note.get_source_display(), "display_name": ResolutionNote.Source.WEB.label,
}, },
"author": { "author": {
"pk": user.public_primary_key, "pk": user.public_primary_key,
@ -50,6 +50,31 @@ def test_create_resolution_note(
assert response.data == result assert response.data == result
@pytest.mark.django_db
def test_create_resolution_note_mobile_app(
make_organization_and_user, make_mobile_app_auth_token_for_user, make_alert_receive_channel, make_alert_group
):
organization, user = make_organization_and_user()
_, token = make_mobile_app_auth_token_for_user(user, organization)
alert_receive_channel = make_alert_receive_channel(organization)
alert_group = make_alert_group(alert_receive_channel)
client = APIClient()
url = reverse("api-internal:resolution_note-list")
data = {
"alert_group": alert_group.public_primary_key,
"text": "Test Message",
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=token)
assert response.status_code == status.HTTP_201_CREATED
assert response.data["source"] == {
"id": ResolutionNote.Source.MOBILE_APP.value,
"display_name": ResolutionNote.Source.MOBILE_APP.label,
}
@pytest.mark.django_db @pytest.mark.django_db
def test_create_resolution_note_invalid_text( def test_create_resolution_note_invalid_text(
make_organization_and_user_with_plugin_token, make_organization_and_user_with_plugin_token,

View file

@ -516,7 +516,11 @@ class AlertGroupView(
rn = ResolutionNote.objects.create( rn = ResolutionNote.objects.create(
alert_group=alert_group, alert_group=alert_group,
author=self.request.user, author=self.request.user,
source=ResolutionNote.Source.WEB, source=(
ResolutionNote.Source.MOBILE_APP
if isinstance(self.request.successful_authenticator, MobileAppAuthTokenAuthentication)
else ResolutionNote.Source.WEB
),
message_text=resolution_note_text[:3000], # trim text to fit in the db field message_text=resolution_note_text[:3000], # trim text to fit in the db field
) )
send_update_resolution_note_signal.apply_async( send_update_resolution_note_signal.apply_async(

View file

@ -19,6 +19,7 @@ type ResolutionNoteSourceTypesOptions = {
[key: number]: string; [key: number]: string;
}; };
export const ResolutionNoteSourceTypesToDisplayName: ResolutionNoteSourceTypesOptions = { export const ResolutionNoteSourceTypesToDisplayName: ResolutionNoteSourceTypesOptions = {
0: 'slack', 0: 'Slack',
1: 'web', 1: 'Web',
2: 'Mobile App',
}; };

View file

@ -520,7 +520,8 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
<VerticalGroup spacing="none"> <VerticalGroup spacing="none">
{item.realm === TimeLineRealm.ResolutionNote && ( {item.realm === TimeLineRealm.ResolutionNote && (
<Text type="secondary" size="small"> <Text type="secondary" size="small">
{item.author && item.author.username} via {ResolutionNoteSourceTypesToDisplayName[item.type]} {item.author && item.author.username} via{' '}
{ResolutionNoteSourceTypesToDisplayName[item.type] || 'Web'}
</Text> </Text>
)} )}
<Text type="primary"> <Text type="primary">