Dev to main (#2282)

Co-authored-by: Michael Derynck <michael.derynck@grafana.com>
Co-authored-by: Joey Orlando <joey.orlando@grafana.com>
Co-authored-by: Matias Bordese <mbordese@gmail.com>
Co-authored-by: Rares Mardare <rares.mardare@grafana.com>
Co-authored-by: Joey Orlando <joseph.t.orlando@gmail.com>
Co-authored-by: Ildar Iskhakov <Ildar.iskhakov@grafana.com>
Co-authored-by: Ruslan Gainanov <gromrx1@gmail.com>
Co-authored-by: Yulia Shanyrova <yulia.shanyrova@grafana.com>
This commit is contained in:
Innokentii Konstantinov 2023-06-19 13:52:34 +08:00 committed by GitHub
parent 4b6e80b27a
commit 5d035808bb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1148 additions and 483 deletions

View file

@ -0,0 +1,108 @@
name: New Bug Report
description: File a bug report
labels:
- bug
body:
- type: markdown
attributes:
value: |
Hi 👋, thanks for taking the time to fill out this bug report!
Please try to give your issue a good title. Try using a brief description of the problem. Like this:
- `When trying to create a schedule, I get an error message when I press on button X` or
- `In Slack, notification Y contains a link to the wrong spot`
- type: markdown
attributes:
value: |
**HINT:** Have you tried [searching](https://github.com/grafana/oncall/issues) for similar issues? Duplicate issues are common.
**Are you reporting a security vulnerability?** [Submit it here instead](https://github.com/grafana/oncall/security/policy).
- type: textarea
id: bug-describe
attributes:
label: What went wrong?
description: |
#
Describe your bug. What happened? What did you expect to happen?
**Pro Tip**: Record your screen and add it here as a gif.
value: |
**What happened**:
-
**What did you expect to happen**:
-
validations:
required: true
- type: textarea
id: bug-repro
attributes:
label: How do we reproduce it?
description: |
#
Whenever possible, please provide **detailed** steps for reproducing your bug.
**This is very helpful info**
value: |
1. Open Grafana OnCall and do X
2. Now click button Y
3. Wait for the browser to crash. Error message says: "Error..."
validations:
required: true
- type: input
id: oncall-version
attributes:
label: Grafana OnCall Version
description: What Grafana OnCall version are you using? If this is related to the Grafana OnCall mobile app, please mention which app version, and OS (plus version), you are running.
placeholder: "ex: v1.1.12, r170-v1.2.43, or v1.0.6 - build 1038 iOS 16.6"
validations:
required: true
- type: dropdown
id: product-area
attributes:
label: Product Area
description: Which Grafana OnCall product area(s) best relate to the issue you're facing?
multiple: true
options:
- Alert Flow & Configuration
- Auth
- Chatops
- Helm
- Mobile App
- Schedules
- Terraform
- Other
validations:
required: true
- type: markdown
attributes:
value: |
# Optional Questions:
- type: dropdown
id: oncall-deployment
attributes:
label: Grafana OnCall Platform?
description: How are you running/deploying Grafana OnCall?
options:
- I use Grafana Cloud
- Docker
- Kubernetes
- Other
- I don't know
validations:
required: false
- type: input
id: user-browser
attributes:
label: User's Browser?
description: Is the bug occuring in the Grafana OnCall web plugin? If so, what browsers are you seeing the problem on? You may choose more than one.
placeholder: "ex. Google Chrome Version 112.0.5615.137 (Official Build) (arm64)..."
validations:
required: false
- type: textarea
id: extra
attributes:
label: Anything else to add?
description: Add any extra information here
validations:
required: false

View file

@ -0,0 +1,48 @@
name: Feature Request
description: Request a new feature in Grafana OnCall!
labels:
- feature request
body:
- type: markdown
attributes:
value: |
Hi 👋, thanks for taking the time to request a new feature!
Please try to give your feature request a good title. Try using a brief description of what you'd like to see. Like this:
- `Add the ability to easily swap an OnCall shift with one of my teammates` or
- `Within Slack, push a button and magically fix my alert`
- type: markdown
attributes:
value: |
**HINT:** Have you tried [searching](https://github.com/grafana/oncall/issues?q=is%3Aopen+is%3Aissue+label%3A%22feature+request%22) for similar feature requests? Duplicate requests are common.
- type: textarea
id: feature-describe
attributes:
label: What would you like to see!
description: Describe what pain-point(s) this new feature would solve. How would you envision it working?
validations:
required: true
- type: dropdown
id: product-area
attributes:
label: Product Area
description: Which Grafana OnCall product area(s) best relate to the issue you're facing?
multiple: true
options:
- Alert Flow & Configuration
- Auth
- Chatops
- Helm
- Mobile App
- Schedules
- Terraform
- Other
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else to add?
description: Add any extra information here
validations:
required: false

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1 @@
blank_issues_enabled: false

View file

@ -1,24 +0,0 @@
---
name: General Issue
about: General requirements to all issues.
title: Specific issue name
labels: ""
assignees: ""
---
`<Remove before publishing>`
Hi 👋, thank you for opening an issue!
Please make sure to add such an info to the issue description:
- [ ] Mention is it's about Cloud or Open Source OnCall.
- [ ] Add OnCall backend & frontend versions.
- [ ] Include labels starting with "part:". Like `part:alertflow` or `part:schedules`. Search for all `part:` labels and
choose the closest one.
- [ ] Include labels like `bug` or `feature request`.
- [ ] If it's a bug, include logs, scheenshots, videos. As much specific info as possible.
Issues mising those items will be closed.
`</Remove before publishing>`

View file

@ -283,6 +283,8 @@ jobs:
mypy:
name: "mypy"
# disable until we fix all the pre-existing errors (aka https://github.com/grafana/oncall/issues/2168 is marked as completed)
if: ${{ false }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

View file

@ -15,9 +15,10 @@ jobs:
project-url: https://github.com/orgs/grafana/projects/119
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
add-latest-version-comment:
name: Add latest version comment to issue
add-latest-version-comment-to-feature-request-issues:
name: Add latest version comment to feature request issues
runs-on: ubuntu-latest
if: contains(github.event.issue.labels.*.name, 'feature request')
permissions:
issues: write
steps:
@ -37,32 +38,43 @@ jobs:
body: |
The current version of Grafana OnCall, at the time this issue was opened, is ${{ steps.get-latest-tag.outputs.tag }}. If your issue pertains to an older version of Grafana OnCall, please be sure to list it in the PR description. Thank you :smile:!
verify-that-atleast-one-label-is-added:
name: Verify that atleast one label is added to issue
# basically only run this job if the issue has no labels at the time of creation
# AND the person creating the issue is a member of the Grafana org
# https://docs.github.com/en/graphql/reference/enums#commentauthorassociation
# MEMBER - Author is a member of the organization that owns the repository.
# OWNER - Author is the owner of the repository.
if: join(github.event.issue.labels.*.name, '') == '' && contains(fromJSON('["MEMBER", "OWNER"]'), github.event.issue.author_association)
map-selected-product-areas-to-labels-and-assignees:
name: Map selected product areas to labels and assignees
runs-on: ubuntu-latest
# try to avoid running this job for an issue that is created via a tasklist
# only run it for issues created via the bug or feature request issue templates
if: contains(github.event.issue.labels.*.name, 'bug') || contains(github.event.issue.labels.*.name, 'feature request')
permissions:
issues: write
steps:
- name: Add comment to remind about adding labels
uses: peter-evans/create-or-update-comment@5f728c3dae25f329afbe34ee4d08eef25569d79f
- uses: actions/checkout@v2
- id: issue-form-values
uses: stefanbuck/github-issue-parser@v3
- run: echo $JSON_STRING
env:
JSON_STRING: ${{ steps.issue-form-values.outputs.jsonString }}
- name: Map mobile app product area to appropriate assignees
uses: actions-ecosystem/action-add-assignees@v1
if: contains(steps.issue-form-values.outputs.issueparser_product_area, 'Mobile App')
with:
issue-number: ${{ github.event.issue.number }}
body: |
Thank you for opening this issue! We noticed that there are currently no labels assigned to the issue :eyes:. Please be sure to add one or more that most accurately describe the issue. In the event that no labels are added, this issue will be automatically closed in 7 days.
# https://docs.github.com/en/actions/managing-issues-and-pull-requests/adding-labels-to-issues#creating-the-workflow
- name: Add no-labels label to issue
uses: actions/github-script@v6
github_token: ${{ secrets.GITHUB_TOKEN }}
assignees: |
imtoori
dieterbe
- name: Map selected product area(s) to issue labels
uses: actions-ecosystem/action-add-labels@v1
# github actions have a weird ternary operator, see below for more details
# https://docs.github.com/en/actions/learn-github-actions/expressions#literals:~:text=GitHub%20offers%20ternary%20operator%20like%20behaviour%20that%20you%20can%20use%20in%20expressions
with:
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["no-labels"]
})
labels: |
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Alert Flow & Configuration') && 'part:alert flow & configuration' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Auth') && 'part:auth/teams' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Chatops') && 'part:chatops' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Helm') && 'part:deployment/helm' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Mobile App') && 'part:mobile' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Schedules') && 'part:schedules' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Terraform') && 'part:API/Terraform' || '' }}
${{ contains(steps.issue-form-values.outputs.issueparser_product_area, 'Other') && 'no info or need to discuss' || '' }}

View file

@ -1,15 +0,0 @@
# The no-labels label is used by actions/stale to auto-close issues
# see triage-unlabeled-issues.yml for more context
name: "Remove no-labels label if present"
on:
issues:
types: [labeled]
jobs:
remove-no-labels-label:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions-ecosystem/action-remove-labels@v1
with:
labels: no-labels

View file

@ -1,26 +0,0 @@
name: "Triage unlabeled issues"
on:
schedule:
- cron: "30 1 * * *"
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v8
with:
# docs - https://github.com/actions/stale
# don't worry about PRs
days-before-pr-stale: -1
days-before-pr-close: -1
# don't worry about marking issues as "stale". There is a separate github action workflow which,
# on issue creation, adds the no-labels label, if no labels were added to the issue when it was created.
# if this label has been present on the issue for 7 days, this workflow will close it
days-before-issue-stale: 6
days-before-issue-close: 7
only-labels: "no-labels"
stale-issue-message: "" # You can skip the comment sending by passing an empty string.
close-issue-message: >
This issue has been automatically closed because it has no labels. Please feel free to re-open and add the appropriate labels :smile:. Thank you for your contributions!

View file

@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.2.45 (2023-06-19)
### Changed
- Change .Values.externalRabbitmq.passwordKey from `password` to `""` (default value `rabbitmq-password`) ([#864](https://github.com/grafana/oncall/pull/864))
- Remove deprecated `permissions` string array from the internal API user serializer by @joeyorlando ([#2269](https://github.com/grafana/oncall/pull/2269))
### Added
- Add `locale` column to mobile app user settings table by @joeyorlando [#2131](https://github.com/grafana/oncall/pull/2131)
- Update notification text for "You're going on call" push notifications to include information about the shift start
and end times by @joeyorlando ([#2131](https://github.com/grafana/oncall/pull/2131))
### Fixed
- Handle non-UTC UNTIL datetime value when repeating ical events [#2241](https://github.com/grafana/oncall/pull/2241)
- Optimize AlertManager auto-resolve mechanism
## v1.2.44 (2023-06-14)
### Added

View file

@ -1870,6 +1870,14 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
return stop_escalation_log
def alerts_count_gt(self, max_alerts) -> bool:
"""
alerts_count_gt checks if there are more than max_alerts alerts in given alert group.
It's optimized for alert groups with big number of alerts and relatively small max_alerts.
"""
count = self.alerts.all()[: max_alerts + 1].count()
return count > max_alerts
@receiver(post_save, sender=AlertGroup)
def listen_for_alertgroup_model_save(sender, instance, created, *args, **kwargs):

View file

@ -22,11 +22,16 @@ def resolve_alert_group_by_source_if_needed(alert_group_pk):
alert_group.active_resolve_calculation_id
)
else:
if alert_group.resolved_by == alert_group.NOT_YET_STOP_AUTORESOLVE:
return "alert_group is too big to auto-resolve"
if alert_group.alerts.count() > AlertGroupForAlertManager.MAX_ALERTS_IN_GROUP_FOR_AUTO_RESOLVE:
is_more_than_max_alerts_in_group = alert_group.alerts_count_gt(
AlertGroupForAlertManager.MAX_ALERTS_IN_GROUP_FOR_AUTO_RESOLVE
)
if is_more_than_max_alerts_in_group:
alert_group.resolved_by = alert_group.NOT_YET_STOP_AUTORESOLVE
alert_group.save(update_fields=["resolved_by"])
if alert_group.resolved_by == alert_group.NOT_YET_STOP_AUTORESOLVE:
return "alert_group is too big to auto-resolve"
print("YOLO")
last_alert = AlertForAlertManager.objects.get(pk=alert_group.alerts.last().pk)
if alert_group.is_alert_a_resolve_signal(last_alert):
alert_group.resolve_by_source()

View file

@ -94,3 +94,26 @@ def test_delete(
with pytest.raises(AlertGroup.DoesNotExist):
alert_group.refresh_from_db()
@pytest.mark.django_db
def test_alerts_count_gt(
make_organization,
make_alert_receive_channel,
make_alert_group,
make_alert,
):
organization = make_organization()
alert_receive_channel = make_alert_receive_channel(organization, integration_slack_channel_id="CWER1ASD")
alert_group = make_alert_group(alert_receive_channel)
# Check case when there is no alerts
assert alert_group.alerts_count_gt(1) is False
make_alert(alert_group, raw_request_data={})
make_alert(alert_group, raw_request_data={})
assert alert_group.alerts_count_gt(1) is True
assert alert_group.alerts_count_gt(2) is False
assert alert_group.alerts_count_gt(3) is False

View file

@ -298,30 +298,3 @@ class IsStaff(permissions.BasePermission):
RBACPermissionsAttribute = typing.Dict[str, typing.List[LegacyAccessControlCompatiblePermission]]
RBACObjectPermissionsAttribute = typing.Dict[permissions.BasePermission, typing.List[str]]
# The below is legacy, it is only needed currently for backward compatibility w/ users running
# older "pinned" version of Grafana in Grafana Cloud
_DONT_USE_LEGACY_VIEWER_PERMISSIONS = []
_DONT_USE_LEGACY_EDITOR_PERMISSIONS = ["update_incidents", "update_own_settings", "view_other_users"]
_DONT_USE_LEGACY_ADMIN_PERMISSIONS = _DONT_USE_LEGACY_EDITOR_PERMISSIONS + [
"update_alert_receive_channels",
"update_escalation_policies",
"update_notification_policies",
"update_general_log_channel_id",
"update_other_users_settings",
"update_integrations",
"update_schedules",
"update_custom_actions",
"update_api_tokens",
"update_teams",
"update_maintenances",
"update_global_settings",
"send_demo_alert",
]
DONT_USE_LEGACY_PERMISSION_MAPPING: typing.Dict[LegacyAccessControlRole, typing.List[str]] = {
LegacyAccessControlRole.VIEWER: _DONT_USE_LEGACY_VIEWER_PERMISSIONS,
LegacyAccessControlRole.EDITOR: _DONT_USE_LEGACY_EDITOR_PERMISSIONS,
LegacyAccessControlRole.ADMIN: _DONT_USE_LEGACY_ADMIN_PERMISSIONS,
}

View file

@ -22,9 +22,11 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se
required=False,
)
slack_channel = serializers.SerializerMethodField()
# Duplicated telegram channel and telegram_channel_details field for backwards compatibility for old integration page
telegram_channel = OrganizationFilteredPrimaryKeyRelatedField(
queryset=TelegramToOrganizationConnector.objects, filter_field="organization", allow_null=True, required=False
)
telegram_channel_details = serializers.SerializerMethodField()
order = serializers.IntegerField(required=False)
filtering_term_as_jinja2 = serializers.SerializerMethodField()
filtering_term = serializers.CharField(required=False, allow_null=True, allow_blank=True)
@ -48,8 +50,13 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se
"notify_in_telegram",
"notification_backends",
"filtering_term_as_jinja2",
"telegram_channel_details",
]
read_only_fields = [
"created_at",
"is_default",
"telegram_channel_details",
]
read_only_fields = ["created_at", "is_default"]
def validate(self, data):
filtering_term = data.get("filtering_term")
@ -77,6 +84,18 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se
"id": obj.slack_channel_pk,
}
def get_telegram_channel_details(self, obj) -> dict[str, any] | None:
if obj.telegram_channel_id is None:
return None
try:
telegram_channel = TelegramToOrganizationConnector.objects.get(pk=obj.telegram_channel_id)
return {
"display_name": telegram_channel.channel_name,
"id": telegram_channel.channel_chat_id,
}
except TelegramToOrganizationConnector.DoesNotExist:
return None
def validate_slack_channel(self, slack_channel_id):
SlackChannel = apps.get_model("slack", "SlackChannel")

View file

@ -33,13 +33,15 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
), # todo: filter by team?
)
updated_shift = serializers.CharField(read_only=True, allow_null=True, source="updated_shift.public_primary_key")
# Name is optional to keep backward compatibility with older frontends
name = serializers.CharField(required=False)
class Meta:
model = CustomOnCallShift
fields = [
"id",
"organization",
"title",
"name",
"type",
"schedule",
"priority_level",
@ -196,9 +198,7 @@ class OnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer):
def create(self, validated_data):
validated_data = self._correct_validated_data(validated_data["type"], validated_data)
validated_data["name"] = CustomOnCallShift.generate_name(
validated_data["schedule"], validated_data["priority_level"], validated_data["type"]
)
# before creation, require users set
self._require_users(validated_data)
instance = super().create(validated_data)
@ -216,16 +216,16 @@ class OnCallShiftUpdateSerializer(OnCallShiftSerializer):
def update(self, instance, validated_data):
validated_data = self._correct_validated_data(instance.type, validated_data)
change_only_title = True
change_only_name = True
create_or_update_last_shift = False
force_update = validated_data.pop("force_update", True)
for field in validated_data:
if field != "title" and validated_data[field] != getattr(instance, field):
change_only_title = False
if field != "name" and validated_data[field] != getattr(instance, field):
change_only_name = False
break
if not change_only_title:
if not change_only_name:
if instance.type != CustomOnCallShift.TYPE_OVERRIDE:
if instance.event_is_started:
create_or_update_last_shift = True

View file

@ -1,11 +1,9 @@
import math
import time
import typing
from django.conf import settings
from rest_framework import serializers
from apps.api.permissions import DONT_USE_LEGACY_PERMISSION_MAPPING
from apps.api.serializers.telegram import TelegramToUserConnectorSerializer
from apps.base.messaging import get_messaging_backends
from apps.base.models import UserNotificationPolicy
@ -37,7 +35,6 @@ class UserSerializer(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)
permissions = serializers.SerializerMethodField()
notification_chain_verbal = serializers.SerializerMethodField()
cloud_connection_status = serializers.SerializerMethodField()
@ -52,7 +49,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
"email",
"username",
"name",
"role", # LEGACY.. this should get removed eventually
"role",
"avatar",
"avatar_full",
"timezone",
@ -62,7 +59,6 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
"slack_user_identity",
"telegram_configuration",
"messaging_backends",
"permissions", # LEGACY.. this should get removed eventually
"notification_chain_verbal",
"cloud_connection_status",
"hide_phone_number",
@ -71,7 +67,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
"email",
"username",
"name",
"role", # LEGACY.. this should get removed eventually
"role",
"verified_phone_number",
]
@ -128,9 +124,6 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
serialized_data[backend_id] = backend.serialize_user(obj)
return serialized_data
def get_permissions(self, obj) -> typing.List[str]:
return DONT_USE_LEGACY_PERMISSION_MAPPING[obj.role]
def get_notification_chain_verbal(self, obj):
default, important = UserNotificationPolicy.get_short_verbals_for_user(user=obj)
return {"default": " - ".join(default), "important": " - ".join(important)}
@ -173,7 +166,6 @@ class UserHiddenFieldsSerializer(UserSerializer):
"timezone",
"working_hours",
"notification_chain_verbal",
"permissions",
]
def to_representation(self, instance):

View file

@ -32,7 +32,7 @@ def test_create_on_call_shift_rotation(on_call_shift_internal_api_setup, make_us
start_date = timezone.now().replace(microsecond=0, tzinfo=None)
data = {
"title": "Test Shift",
"name": "Test Shift",
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"schedule": schedule.public_primary_key,
"priority_level": 1,
@ -97,7 +97,7 @@ def test_create_on_call_shift_override(on_call_shift_internal_api_setup, make_us
start_date = timezone.now().replace(microsecond=0, tzinfo=None)
data = {
"title": "Test Shift Override",
"name": "Test Shift Override",
"type": CustomOnCallShift.TYPE_OVERRIDE,
"schedule": schedule.public_primary_key,
"priority_level": 99,
@ -137,12 +137,12 @@ def test_get_on_call_shift(
client = APIClient()
start_date = timezone.now().replace(microsecond=0)
title = "Test Shift Rotation"
name = "Test Shift Rotation"
on_call_shift = make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
name=name,
start=start_date,
duration=timezone.timedelta(hours=1),
rotation_start=start_date,
@ -153,7 +153,7 @@ def test_get_on_call_shift(
response = client.get(url, format="json", **make_user_auth_headers(user1, token))
expected_payload = {
"id": response.data["id"],
"title": title,
"name": name,
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -183,12 +183,12 @@ def test_list_on_call_shift(
client = APIClient()
start_date = timezone.now().replace(microsecond=0)
title = "Test Shift Rotation"
name = "Test Shift Rotation"
on_call_shift = make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
name=name,
start=start_date,
duration=timezone.timedelta(hours=1),
rotation_start=start_date,
@ -204,7 +204,7 @@ def test_list_on_call_shift(
"results": [
{
"id": on_call_shift.public_primary_key,
"title": title,
"name": name,
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -239,12 +239,12 @@ def test_list_on_call_shift_filter_schedule_id(
client = APIClient()
start_date = timezone.now().replace(microsecond=0)
title = "Test Shift Rotation"
name = "Test Shift Rotation"
on_call_shift = make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
name=name,
start=start_date,
duration=timezone.timedelta(hours=1),
rotation_start=start_date,
@ -262,7 +262,7 @@ def test_list_on_call_shift_filter_schedule_id(
"results": [
{
"id": on_call_shift.public_primary_key,
"title": title,
"name": name,
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -312,19 +312,19 @@ def test_update_future_on_call_shift(
client = APIClient()
start_date = (timezone.now() + timezone.timedelta(days=1)).replace(microsecond=0)
title = "Test Shift Rotation"
name = "Test Shift Rotation"
on_call_shift = make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
name=name,
start=start_date,
duration=timezone.timedelta(hours=1),
rotation_start=start_date,
rolling_users=[{user1.pk: user1.public_primary_key}],
)
data_to_update = {
"title": title,
"name": name,
"priority_level": 2,
"shift_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
"shift_end": (start_date + timezone.timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
@ -344,7 +344,7 @@ def test_update_future_on_call_shift(
expected_payload = {
"id": on_call_shift.public_primary_key,
"title": title,
"name": name,
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"schedule": schedule.public_primary_key,
"priority_level": 2,
@ -422,19 +422,19 @@ def test_update_started_on_call_shift(
client = APIClient()
start_date = (timezone.now() - timezone.timedelta(hours=1)).replace(microsecond=0)
title = "Test Shift Rotation"
name = "Test Shift Rotation"
on_call_shift = make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
name=name,
start=start_date,
duration=timezone.timedelta(hours=3),
rotation_start=start_date,
rolling_users=[{user1.pk: user1.public_primary_key}],
)
data_to_update = {
"title": title,
"name": name,
"priority_level": 2,
"shift_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
"shift_end": (start_date + timezone.timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
@ -454,7 +454,7 @@ def test_update_started_on_call_shift(
expected_payload = {
"id": response.data["id"],
"title": title,
"name": name,
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"schedule": schedule.public_primary_key,
"priority_level": 2,
@ -494,19 +494,19 @@ def test_update_started_on_call_shift_force_update(
client = APIClient()
start_date = (timezone.now() - timezone.timedelta(hours=1)).replace(microsecond=0)
title = "Test Shift Rotation"
name = "Test Shift Rotation"
on_call_shift = make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
name=name,
start=start_date,
duration=timezone.timedelta(hours=3),
rotation_start=start_date,
rolling_users=[{user1.pk: user1.public_primary_key}],
)
data_to_update = {
"title": title,
"name": name,
"priority_level": 2,
"shift_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
"shift_end": (start_date + timezone.timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
@ -549,12 +549,12 @@ def test_update_old_on_call_shift_with_future_version(
next_rotation_start_date = now + timezone.timedelta(days=1)
updated_duration = timezone.timedelta(hours=4)
title = "Test Shift Rotation"
name = "Test Shift Rotation"
new_on_call_shift = make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
name=name,
start=next_rotation_start_date,
duration=timezone.timedelta(hours=3),
rotation_start=next_rotation_start_date,
@ -565,7 +565,7 @@ def test_update_old_on_call_shift_with_future_version(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
name=name,
start=start_date,
duration=timezone.timedelta(hours=3),
rotation_start=start_date,
@ -576,7 +576,7 @@ def test_update_old_on_call_shift_with_future_version(
)
# update shift_end and priority_level
data_to_update = {
"title": title,
"name": name,
"priority_level": 2,
"shift_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
"shift_end": (start_date + updated_duration).strftime("%Y-%m-%dT%H:%M:%SZ"),
@ -624,26 +624,26 @@ def test_update_old_on_call_shift_with_future_version(
@pytest.mark.django_db
def test_update_started_on_call_shift_title(
def test_update_started_on_call_shift_name(
on_call_shift_internal_api_setup,
make_on_call_shift,
make_user_auth_headers,
):
"""Test updating the title for the shift that has started (rotation_start < now)"""
"""Test updating the name for the shift that has started (rotation_start < now)"""
token, user1, _, _, schedule = on_call_shift_internal_api_setup
client = APIClient()
start_date = (timezone.now() - timezone.timedelta(hours=1)).replace(microsecond=0)
title = "Test Shift Rotation"
new_title = "Test Shift Rotation RENAMED"
name = "Test Shift Rotation"
new_name = "Test Shift Rotation RENAMED"
on_call_shift = make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
name=name,
start=start_date,
duration=timezone.timedelta(hours=1),
rotation_start=start_date,
@ -651,9 +651,9 @@ def test_update_started_on_call_shift_title(
source=CustomOnCallShift.SOURCE_WEB,
week_start=CustomOnCallShift.MONDAY,
)
# update only title
# update only name
data_to_update = {
"title": new_title,
"name": new_name,
"priority_level": 0,
"shift_start": start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
"shift_end": (start_date + timezone.timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
@ -666,7 +666,7 @@ def test_update_started_on_call_shift_title(
"rolling_users": [[user1.public_primary_key]],
}
assert on_call_shift.title != new_title
assert on_call_shift.name != new_name
url = reverse("api-internal:oncall_shifts-detail", kwargs={"pk": on_call_shift.public_primary_key})
@ -684,7 +684,7 @@ def test_update_started_on_call_shift_title(
assert response.json() == expected_payload
on_call_shift.refresh_from_db()
assert on_call_shift.title == new_title
assert on_call_shift.name == new_name
@pytest.mark.django_db
@ -700,13 +700,13 @@ def test_delete_started_on_call_shift(
client = APIClient()
start_date = (timezone.now() - timezone.timedelta(hours=1)).replace(microsecond=0)
title = "Test Shift Rotation"
name = "Test Shift Rotation"
on_call_shift = make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
name=name,
start=start_date,
duration=timezone.timedelta(hours=1),
rotation_start=start_date,
@ -738,13 +738,13 @@ def test_force_delete_started_on_call_shift(
client = APIClient()
start_date = (timezone.now() - timezone.timedelta(hours=1)).replace(microsecond=0)
title = "Test Shift Rotation"
name = "Test Shift Rotation"
on_call_shift = make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
name=name,
start=start_date,
duration=timezone.timedelta(hours=1),
rotation_start=start_date,
@ -777,13 +777,13 @@ def test_delete_future_on_call_shift(
client = APIClient()
start_date = (timezone.now() + timezone.timedelta(days=1)).replace(microsecond=0)
title = "Test Shift Rotation"
name = "Test Shift Rotation"
on_call_shift = make_on_call_shift(
schedule.organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
schedule=schedule,
title=title,
name=name,
start=start_date,
duration=timezone.timedelta(hours=1),
rotation_start=start_date,
@ -812,7 +812,7 @@ def test_create_on_call_shift_invalid_data_rotation_start(
# rotation_start < shift_start
data = {
"title": "Test Shift 1",
"name": "Test Shift 1",
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -841,7 +841,7 @@ def test_create_on_call_shift_invalid_data_until(on_call_shift_internal_api_setu
# until < rotation_start
data = {
"title": "Test Shift",
"name": "Test Shift",
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"schedule": schedule.public_primary_key,
"priority_level": 1,
@ -865,7 +865,7 @@ def test_create_on_call_shift_invalid_data_until(on_call_shift_internal_api_setu
# until with non-recurrent shift
data = {
"title": "Test Shift 2",
"name": "Test Shift 2",
"type": CustomOnCallShift.TYPE_OVERRIDE,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -894,7 +894,7 @@ def test_create_on_call_shift_invalid_data_by_day(on_call_shift_internal_api_set
# by_day with non-recurrent shift
data = {
"title": "Test Shift 1",
"name": "Test Shift 1",
"type": CustomOnCallShift.TYPE_OVERRIDE,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -923,7 +923,7 @@ def test_create_on_call_shift_invalid_data_interval(on_call_shift_internal_api_s
# interval with non-recurrent shift
data = {
"title": "Test Shift 2",
"name": "Test Shift 2",
"type": CustomOnCallShift.TYPE_OVERRIDE,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -949,7 +949,7 @@ def test_create_on_call_shift_invalid_data_interval(on_call_shift_internal_api_s
for interval, expected_error in invalid_intervals:
# by_day, daily shift
data = {
"title": "Test Shift 2",
"name": "Test Shift 2",
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -979,7 +979,7 @@ def test_create_on_call_shift_invalid_data_shift_end(on_call_shift_internal_api_
# shift_end is None
data = {
"title": "Test Shift 1",
"name": "Test Shift 1",
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -1000,7 +1000,7 @@ def test_create_on_call_shift_invalid_data_shift_end(on_call_shift_internal_api_
# shift_end < shift_start
data = {
"title": "Test Shift 2",
"name": "Test Shift 2",
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -1031,7 +1031,7 @@ def test_create_on_call_shift_invalid_data_rolling_users(
start_date = timezone.now().replace(microsecond=0, tzinfo=None)
data = {
"title": "Test Shift 1",
"name": "Test Shift 1",
"type": CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -1060,7 +1060,7 @@ def test_create_on_call_shift_override_invalid_data(on_call_shift_internal_api_s
# override shift with frequency
data = {
"title": "Test Shift Override",
"name": "Test Shift Override",
"type": CustomOnCallShift.TYPE_OVERRIDE,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -1088,7 +1088,7 @@ def test_create_on_call_shift_override_in_past(on_call_shift_internal_api_setup,
start_date = timezone.now().replace(microsecond=0, tzinfo=None) - timezone.timedelta(hours=2)
data = {
"title": "Test Shift Override",
"name": "Test Shift Override",
"type": CustomOnCallShift.TYPE_OVERRIDE,
"schedule": schedule.public_primary_key,
"priority_level": 0,
@ -1771,13 +1771,10 @@ def test_on_call_shift_preview_update(
# check rotation events
rotation_events = response.json()["rotation"]
assert len(rotation_events) == 4
# the final original rotation events are returned and the ID is kept
for shift in rotation_events[:3]:
assert shift["shift"]["pk"] == on_call_shift.public_primary_key
# previewing an update does not reuse shift PK if rotation already started
assert len(rotation_events) == 1
# previewing an update reuse shift PK if rotation already started
new_shift_pk = rotation_events[-1]["shift"]["pk"]
assert new_shift_pk != on_call_shift.public_primary_key
assert new_shift_pk == on_call_shift.public_primary_key
expected_shift_preview = {
"calendar_type": OnCallSchedule.TYPE_ICAL_PRIMARY,
"shift": {"pk": new_shift_pk},
@ -1800,9 +1797,6 @@ def test_on_call_shift_preview_update(
final_events = response.json()["final"]
expected = (
# start (h), duration (H), user, priority
(0, 1, user.username, 1), # 0-1 user
(4, 1, user.username, 1), # 4-5 user
(8, 1, user.username, 1), # 8-9 user
(10, 8, other_user.username, 1), # 10-18 other_user
)
expected_events = [

View file

@ -10,12 +10,7 @@ from rest_framework import status
from rest_framework.response import Response
from rest_framework.test import APIClient
from apps.api.permissions import (
DONT_USE_LEGACY_PERMISSION_MAPPING,
GrafanaAPIPermission,
LegacyAccessControlRole,
RBACPermission,
)
from apps.api.permissions import GrafanaAPIPermission, LegacyAccessControlRole, RBACPermission
from apps.base.models import UserNotificationPolicy
from apps.phone_notifications.exceptions import FailedToFinishVerification
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
@ -96,7 +91,6 @@ def test_update_user_cant_change_email_and_username(
}
},
"cloud_connection_status": None,
"permissions": DONT_USE_LEGACY_PERMISSION_MAPPING[admin.role],
"notification_chain_verbal": {"default": "", "important": ""},
"slack_user_identity": None,
"avatar": admin.avatar_url,
@ -147,7 +141,6 @@ def test_list_users(
"user": admin.username,
}
},
"permissions": DONT_USE_LEGACY_PERMISSION_MAPPING[admin.role],
"notification_chain_verbal": {"default": "", "important": ""},
"slack_user_identity": None,
"avatar": admin.avatar_url,
@ -173,7 +166,6 @@ def test_list_users(
"user": editor.username,
}
},
"permissions": DONT_USE_LEGACY_PERMISSION_MAPPING[editor.role],
"notification_chain_verbal": {"default": "", "important": ""},
"slack_user_identity": None,
"avatar": editor.avatar_url,

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.19 on 2023-06-08 10:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mobile_app', '0007_alter_mobileappusersettings_info_notifications_enabled'),
]
operations = [
migrations.AddField(
model_name='mobileappusersettings',
name='locale',
field=models.CharField(max_length=50, null=True),
),
]

View file

@ -141,3 +141,5 @@ class MobileAppUserSettings(models.Model):
going_oncall_notification_timing = models.IntegerField(
choices=NOTIFICATION_TIMING_CHOICES, default=TWELVE_HOURS_IN_SECONDS
)
locale = models.CharField(max_length=50, null=True)

View file

@ -22,4 +22,5 @@ class MobileAppUserSettingsSerializer(serializers.ModelSerializer):
"important_notification_override_dnd",
"info_notifications_enabled",
"going_oncall_notification_timing",
"locale",
)

View file

@ -24,6 +24,7 @@ from apps.schedules.models.on_call_schedule import OnCallSchedule, ScheduleEvent
from apps.user_management.models import User
from common.api_helpers.utils import create_engine_url
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
from common.l10n import format_localized_datetime, format_localized_time
if typing.TYPE_CHECKING:
from apps.mobile_app.models import MobileAppUserSettings
@ -225,8 +226,33 @@ def _get_alert_group_escalation_fcm_message(
return _construct_fcm_message(message_type, device_to_notify, thread_id, fcm_message_data, apns_payload)
def _get_youre_going_oncall_notification_title(
schedule: OnCallSchedule,
seconds_until_going_oncall: int,
schedule_event: ScheduleEvent,
mobile_app_user_settings: "MobileAppUserSettings",
) -> str:
time_until_going_oncall = humanize.naturaldelta(seconds_until_going_oncall)
shift_start = schedule_event["start"]
shift_end = schedule_event["end"]
shift_starts_and_ends_on_same_day = shift_start.date() == shift_end.date()
dt_formatter_func = format_localized_time if shift_starts_and_ends_on_same_day else format_localized_datetime
def _format_datetime(dt):
return dt_formatter_func(dt, mobile_app_user_settings.locale)
formatted_shift = f"{_format_datetime(shift_start)} - {_format_datetime(shift_end)}"
return f"You're going on call in {time_until_going_oncall} for schedule {schedule.name}, {formatted_shift}"
def _get_youre_going_oncall_fcm_message(
user: User, schedule: OnCallSchedule, device_to_notify: FCMDevice, seconds_until_going_oncall: int
user: User,
schedule: OnCallSchedule,
device_to_notify: FCMDevice,
seconds_until_going_oncall: int,
schedule_event: ScheduleEvent,
) -> Message:
# avoid circular import
from apps.mobile_app.models import MobileAppUserSettings
@ -235,8 +261,8 @@ def _get_youre_going_oncall_fcm_message(
mobile_app_user_settings, _ = MobileAppUserSettings.objects.get_or_create(user=user)
notification_title = (
f"You are going on call in {humanize.naturaldelta(seconds_until_going_oncall)} for schedule {schedule.name}"
notification_title = _get_youre_going_oncall_notification_title(
schedule, seconds_until_going_oncall, schedule_event, mobile_app_user_settings
)
data: FCMMessageData = {
@ -446,7 +472,7 @@ def conditionally_send_going_oncall_push_notifications_for_schedule(schedule_pk)
if seconds_until_going_oncall is not None and not already_sent_this_push_notification:
message = _get_youre_going_oncall_fcm_message(
user, schedule, device_to_notify, seconds_until_going_oncall
user, schedule, device_to_notify, seconds_until_going_oncall, schedule_event
)
_send_push_notification(device_to_notify, message)
cache.set(cache_key, True, PUSH_NOTIFICATION_TRACKING_CACHE_KEY_TTL)

View file

@ -31,6 +31,7 @@ def test_user_settings_get(make_organization_and_user_with_mobile_app_auth_token
"important_notification_override_dnd": True,
"info_notifications_enabled": False,
"going_oncall_notification_timing": 43200,
"locale": None,
}
@ -67,6 +68,7 @@ def test_user_settings_put(
"important_notification_override_dnd": False,
"info_notifications_enabled": True,
"going_oncall_notification_timing": going_oncall_notification_timing,
"locale": "ca_FR",
}
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=auth_token)
@ -75,3 +77,42 @@ def test_user_settings_put(
if expected_status_code == status.HTTP_200_OK:
# Check the values are updated correctly
assert response.json() == data
@pytest.mark.django_db
def test_user_settings_patch(make_organization_and_user_with_mobile_app_auth_token):
_, _, auth_token = make_organization_and_user_with_mobile_app_auth_token()
original_default_notification_sound_name = "test_default"
patch_default_notification_sound_name = "test_default_patched"
client = APIClient()
url = reverse("mobile_app:user_settings")
data = {
"default_notification_sound_name": original_default_notification_sound_name,
"default_notification_volume_type": "intensifying",
"default_notification_volume": 1,
"default_notification_volume_override": True,
"info_notification_sound_name": "default_sound",
"info_notification_volume_type": "constant",
"info_notification_volume": 0.8,
"info_notification_volume_override": False,
"important_notification_sound_name": "test_important",
"important_notification_volume_type": "intensifying",
"important_notification_volume": 1,
"important_notification_volume_override": False,
"important_notification_override_dnd": False,
"info_notifications_enabled": True,
}
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=auth_token)
original_settings = response.json()
assert response.status_code == status.HTTP_200_OK
patch_data = {"default_notification_sound_name": patch_default_notification_sound_name}
response = client.patch(url, data=patch_data, format="json", HTTP_AUTHORIZATION=auth_token)
assert response.status_code == status.HTTP_200_OK
# all original settings should stay the same, only data set in PATCH call should get updated
assert response.json() == {**original_settings, **patch_data}

View file

@ -1,3 +1,4 @@
import json
import typing
from unittest import mock
@ -25,9 +26,9 @@ def clear_cache():
def _create_schedule_event(
start_time: timezone.datetime, shift_pk: str, users: typing.List[ScheduleEventUser]
start_time: timezone.datetime, end_time: timezone.datetime, shift_pk: str, users: typing.List[ScheduleEventUser]
) -> ScheduleEvent:
return {"start": start_time, "shift": {"pk": shift_pk}, "users": users}
return {"start": start_time, "end": end_time, "shift": {"pk": shift_pk}, "users": users}
@pytest.mark.parametrize(
@ -47,6 +48,171 @@ def test_shift_starts_within_range(timing_window_lower, timing_window_upper, sec
)
@pytest.mark.django_db
def test_get_youre_going_oncall_notification_title(make_organization_and_user, make_user, make_schedule):
schedule_name = "asdfasdfasdfasdf"
organization, user = make_organization_and_user()
user2 = make_user(organization=organization)
schedule = make_schedule(organization, name=schedule_name, schedule_class=OnCallScheduleWeb)
shift_pk = "mncvmnvc"
user_pk = user.public_primary_key
user_locale = "fr_CA"
seconds_until_going_oncall = 600
humanized_time_until_going_oncall = "10 minutes"
same_day_shift_start = timezone.datetime(2023, 7, 8, 9, 0, 0)
same_day_shift_end = timezone.datetime(2023, 7, 8, 17, 0, 0)
multiple_day_shift_start = timezone.datetime(2023, 7, 8, 9, 0, 0)
multiple_day_shift_end = timezone.datetime(2023, 7, 12, 17, 0, 0)
same_day_shift = _create_schedule_event(
same_day_shift_start,
same_day_shift_end,
shift_pk,
[
{
"pk": user_pk,
},
],
)
multiple_day_shift = _create_schedule_event(
multiple_day_shift_start,
multiple_day_shift_end,
shift_pk,
[
{
"pk": user_pk,
},
],
)
maus = MobileAppUserSettings.objects.create(user=user, locale=user_locale)
maus_no_locale = MobileAppUserSettings.objects.create(user=user2)
##################
# same day shift
##################
same_day_shift_title = tasks._get_youre_going_oncall_notification_title(
schedule, seconds_until_going_oncall, same_day_shift, maus
)
same_day_shift_no_locale_title = tasks._get_youre_going_oncall_notification_title(
schedule, seconds_until_going_oncall, same_day_shift, maus_no_locale
)
assert (
same_day_shift_title
== f"You're going on call in {humanized_time_until_going_oncall} for schedule {schedule_name}, 09 h 00 - 17 h 00"
)
assert (
same_day_shift_no_locale_title
== f"You're going on call in {humanized_time_until_going_oncall} for schedule {schedule_name}, 9:00\u202fAM - 5:00\u202fPM"
)
##################
# multiple day shift
##################
multiple_day_shift_title = tasks._get_youre_going_oncall_notification_title(
schedule, seconds_until_going_oncall, multiple_day_shift, maus
)
multiple_day_shift_no_locale_title = tasks._get_youre_going_oncall_notification_title(
schedule, seconds_until_going_oncall, multiple_day_shift, maus_no_locale
)
assert (
multiple_day_shift_title
== f"You're going on call in {humanized_time_until_going_oncall} for schedule {schedule_name}, 2023-07-08 09 h 00 - 2023-07-12 17 h 00"
)
assert (
multiple_day_shift_no_locale_title
== f"You're going on call in {humanized_time_until_going_oncall} for schedule {schedule_name}, 7/8/23, 9:00\u202fAM - 7/12/23, 5:00\u202fPM"
)
@mock.patch("apps.mobile_app.tasks._get_youre_going_oncall_notification_title")
@mock.patch("apps.mobile_app.tasks._construct_fcm_message")
@mock.patch("apps.mobile_app.tasks.APNSPayload")
@mock.patch("apps.mobile_app.tasks.Aps")
@mock.patch("apps.mobile_app.tasks.ApsAlert")
@mock.patch("apps.mobile_app.tasks.CriticalSound")
@pytest.mark.django_db
def test_get_youre_going_oncall_fcm_message(
mock_critical_sound,
mock_aps_alert,
mock_aps,
mock_apns_payload,
mock_construct_fcm_message,
mock_get_youre_going_oncall_notification_title,
make_organization_and_user,
make_schedule,
):
mock_fcm_message = "mncvmnvcmnvcnmvcmncvmn"
mock_notification_title = "asdfasdf"
shift_pk = "mncvmnvc"
seconds_until_going_oncall = 600
mock_construct_fcm_message.return_value = mock_fcm_message
mock_get_youre_going_oncall_notification_title.return_value = mock_notification_title
organization, user = make_organization_and_user()
user_pk = user.public_primary_key
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
notification_thread_id = f"{schedule.public_primary_key}:{user_pk}:going-oncall"
schedule_event = _create_schedule_event(
timezone.now(),
timezone.now(),
shift_pk,
[
{
"pk": user_pk,
},
],
)
device = FCMDevice.objects.create(user=user)
maus = MobileAppUserSettings.objects.create(user=user)
data = {
"title": mock_notification_title,
"info_notification_sound_name": (
maus.info_notification_sound_name + MobileAppUserSettings.ANDROID_SOUND_NAME_EXTENSION
),
"info_notification_volume_type": maus.info_notification_volume_type,
"info_notification_volume": str(maus.info_notification_volume),
"info_notification_volume_override": json.dumps(maus.info_notification_volume_override),
}
fcm_message = tasks._get_youre_going_oncall_fcm_message(
user, schedule, device, seconds_until_going_oncall, schedule_event
)
assert fcm_message == mock_fcm_message
mock_aps_alert.assert_called_once_with(title=mock_notification_title)
mock_critical_sound.assert_called_once_with(
critical=False, name=maus.info_notification_sound_name + MobileAppUserSettings.IOS_SOUND_NAME_EXTENSION
)
mock_aps.assert_called_once_with(
thread_id=notification_thread_id,
alert=mock_aps_alert.return_value,
sound=mock_critical_sound.return_value,
custom_data={
"interruption-level": "time-sensitive",
},
)
mock_apns_payload.assert_called_once_with(aps=mock_aps.return_value)
mock_get_youre_going_oncall_notification_title.assert_called_once_with(
schedule, seconds_until_going_oncall, schedule_event, maus
)
mock_construct_fcm_message.assert_called_once_with(
tasks.MessageType.INFO, device, notification_thread_id, data, mock_apns_payload.return_value
)
@pytest.mark.parametrize(
"info_notifications_enabled,now,going_oncall_notification_timing,schedule_start,expected",
[
@ -162,7 +328,7 @@ def test_should_we_send_going_oncall_push_notification(
assert (
tasks.should_we_send_going_oncall_push_notification(
now, user_mobile_settings, _create_schedule_event(schedule_start, "12345", [])
now, user_mobile_settings, _create_schedule_event(schedule_start, schedule_start, "12345", [])
)
== expected
)
@ -205,17 +371,18 @@ def test_conditionally_send_going_oncall_push_notifications_for_schedule(
shift_pk = "mncvmnvc"
user_pk = user.public_primary_key
mock_fcm_message = {"foo": "bar"}
final_events = [
_create_schedule_event(
timezone.now(),
shift_pk,
[
{
"pk": user_pk,
},
],
),
]
schedule_event = _create_schedule_event(
timezone.now(),
timezone.now(),
shift_pk,
[
{
"pk": user_pk,
},
],
)
final_events = [schedule_event]
seconds_until_shift_starts = 58989
mock_get_youre_going_oncall_fcm_message.return_value = mock_fcm_message
@ -237,7 +404,9 @@ def test_conditionally_send_going_oncall_push_notifications_for_schedule(
tasks.conditionally_send_going_oncall_push_notifications_for_schedule(schedule.pk)
mock_get_youre_going_oncall_fcm_message.assert_called_once_with(user, schedule, device, seconds_until_shift_starts)
mock_get_youre_going_oncall_fcm_message.assert_called_once_with(
user, schedule, device, seconds_until_shift_starts, schedule_event
)
mock_send_push_notification.assert_called_once_with(device, mock_fcm_message)
assert cache.get(cache_key) is True

View file

@ -144,19 +144,6 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer
instance.start_drop_ical_and_check_schedule_tasks(schedule)
return instance
def validate_name(self, name):
organization = self.context["request"].auth.organization
if name is None:
return name
try:
obj = CustomOnCallShift.objects.get(organization=organization, name=name)
except CustomOnCallShift.DoesNotExist:
return name
if self.instance and obj.id == self.instance.id:
return name
else:
raise BadRequest(detail="On-call shift with this name already exists")
def validate_by_day(self, by_day):
if by_day:
for day in by_day:

View file

@ -1,4 +1,5 @@
import datetime
import re
import typing
from collections import defaultdict
@ -30,6 +31,8 @@ class AmixrUnfoldableCalendar(UnfoldableCalendar):
"""
class RepeatedEvent(UnfoldableCalendar.RepeatedEvent):
RE_DATETIME_VALUE = re.compile(r"\d+T\d+")
class Repetition(UnfoldableCalendar.RepeatedEvent.Repetition):
"""
A repetition of an event. Overridden version of
@ -40,6 +43,26 @@ class AmixrUnfoldableCalendar(UnfoldableCalendar):
ATTRIBUTES_TO_DELETE_ON_COPY = ["RDATE", "EXDATE"]
def create_rule_with_start(self, rule_string, start):
"""Override to handle issue with non-UTC UNTIL value including time information."""
try:
return super().create_rule_with_start(rule_string, start)
except ValueError:
# string: FREQ=WEEKLY;UNTIL=20191023T100000;BYDAY=TH;WKST=SU
# ValueError: RRULE UNTIL values must be specified in UTC when DTSTART is timezone-aware
# https://stackoverflow.com/a/49991809
rule_list = rule_string.split(";UNTIL=")
assert len(rule_list) == 2
date_end_index = rule_list[1].find(";")
if date_end_index == -1:
date_end_index = len(rule_list[1])
until_string = rule_list[1][:date_end_index]
if self.RE_DATETIME_VALUE.match(until_string):
rule_string = rule_list[0] + rule_list[1][date_end_index:] + ";UNTIL=" + until_string + "Z"
return super().create_rule_with_start(rule_string, self.start)
# otherwise, keep raising
raise
def between(self, start, stop):
"""Return events at a time between start (inclusive) and end (inclusive)"""
span_start = self.to_datetime(start)

View file

@ -0,0 +1,39 @@
# Generated by Django 3.2.18 on 2023-05-17 05:10
from django.db import migrations, models
from django.db.models import F
SOURCE_WEB = 0
def drop_autogenerated_web_shift_name(apps, schema_editor):
CustomOnCallShift = apps.get_model('schedules', 'CustomOnCallShift')
CustomOnCallShift.objects.filter(source=SOURCE_WEB).update(name=None)
def autogenerate_web_shift_name(apps, schema_editor):
CustomOnCallShift = apps.get_model('schedules', 'CustomOnCallShift')
shifts_from_web = CustomOnCallShift.objects.filter(source=SOURCE_WEB)
shifts_from_web.update(name=F("public_primary_key")) # set some uniq name to make migration reversible
class Migration(migrations.Migration):
dependencies = [
('schedules', '0012_auto_20230502_1259'),
]
operations = [
migrations.AlterUniqueTogether(
name='customoncallshift',
unique_together=set(),
),
migrations.RemoveField(
model_name='customoncallshift',
name='title',
),
migrations.AlterField(
model_name='customoncallshift',
name='name',
field=models.CharField(default=None, max_length=200, null=True),
),
migrations.RunPython(drop_autogenerated_web_shift_name, autogenerate_web_shift_name),
]

View file

@ -2,8 +2,6 @@ import copy
import datetime
import itertools
import logging
import random
import string
from calendar import monthrange
from uuid import uuid4
@ -172,8 +170,7 @@ class CustomOnCallShift(models.Model):
null=True,
default=None,
)
name = models.CharField(max_length=200)
title = models.CharField(max_length=200, null=True, default=None)
name = models.CharField(max_length=200, null=True, default=None)
time_zone = models.CharField(max_length=100, null=True, default=None)
source = models.IntegerField(choices=SOURCE_CHOICES, default=SOURCE_API)
users = models.ManyToManyField("user_management.User") # users in single and recurrent events
@ -213,9 +210,6 @@ class CustomOnCallShift(models.Model):
related_name="parent_shift",
)
class Meta:
unique_together = ("name", "organization")
def delete(self, *args, **kwargs):
schedules_to_update = list(self.schedules.all())
if self.schedule:
@ -237,6 +231,13 @@ class CustomOnCallShift(models.Model):
self.duration = delta
update_fields += ["duration"]
self.save(update_fields=update_fields)
elif self.schedule:
# for web schedule shifts to be hard-deleted, update the rotation updated_shift links
previous_shift = self.schedule.custom_shifts.filter(updated_shift=self).first()
super().delete(*args, **kwargs)
if previous_shift:
previous_shift.updated_shift = self.updated_shift
previous_shift.save(update_fields=["updated_shift"])
else:
super().delete(*args, **kwargs)
@ -323,12 +324,14 @@ class CustomOnCallShift(models.Model):
break
last_start = start
day = CustomOnCallShift.ICAL_WEEKDAY_MAP[start.weekday()]
if (user_group_id, day, i) in combinations:
all_rotations_checked = True
break
# double-check day is valid (when until is set, we may get unexpected days)
if day in self.by_day:
if (user_group_id, day, i) in combinations:
all_rotations_checked = True
break
starting_dates.append(start)
combinations.append((user_group_id, day, i))
starting_dates.append(start)
combinations.append((user_group_id, day, i))
# get next event date following the original rule
event_ical = self.generate_ical(start, 1, None, 1, time_zone, custom_rrule=day_by_day_rrule)
start = self.get_rotation_date(event_ical, get_next_date=True, interval=1)
@ -385,7 +388,10 @@ class CustomOnCallShift(models.Model):
if self.frequency is not None and self.by_day and start is not None:
start_day = CustomOnCallShift.ICAL_WEEKDAY_MAP[start.weekday()]
if start_day not in self.by_day:
expected_start_day = min(CustomOnCallShift.ICAL_WEEKDAY_REVERSE_MAP[d] for d in self.by_day)
# when calculating first start date, make sure to sort days using week_start
sorted_days = [i % 7 for i in range(self.week_start, self.week_start + 7)]
selected_days = [CustomOnCallShift.ICAL_WEEKDAY_REVERSE_MAP[d] for d in self.by_day]
expected_start_day = [d for d in sorted_days if d in selected_days][0]
delta = (expected_start_day - start.weekday()) % 7
start = start + datetime.timedelta(days=delta)
@ -687,7 +693,7 @@ class CustomOnCallShift(models.Model):
# prepare dict with params of existing instance with last updates and remove unique and m2m fields from it
shift_to_update = self.last_updated_shift or self
instance_data = model_to_dict(shift_to_update)
fields_to_remove = ["id", "public_primary_key", "uuid", "users", "updated_shift", "name"]
fields_to_remove = ["id", "public_primary_key", "uuid", "users", "updated_shift"]
for field in fields_to_remove:
instance_data.pop(field)
@ -705,9 +711,6 @@ class CustomOnCallShift(models.Model):
if self.last_updated_shift is None or self.last_updated_shift.event_is_started:
# create new shift
instance_data["name"] = CustomOnCallShift.generate_name(
self.schedule, instance_data["priority_level"], instance_data["type"]
)
with transaction.atomic():
shift = CustomOnCallShift(**instance_data)
shift.save()
@ -722,13 +725,6 @@ class CustomOnCallShift(models.Model):
return shift
@staticmethod
def generate_name(schedule, priority_level, shift_type):
shift_type_name = "override" if shift_type == CustomOnCallShift.TYPE_OVERRIDE else "rotation"
name = f"{schedule.name}-{shift_type_name}-{priority_level}-"
name += "".join(random.choice(string.ascii_lowercase) for _ in range(5))
return name
# Insight logs
@property
def insight_logs_type_verbal(self):

View file

@ -764,22 +764,9 @@ class OnCallSchedule(PolymorphicModel):
extra_shifts = [custom_shift]
if updated_shift_pk is not None:
try:
update_shift = qs.get(public_primary_key=updated_shift_pk)
except CustomOnCallShift.DoesNotExist:
pass
else:
if update_shift.event_is_started:
custom_shift.rotation_start = max(
custom_shift.rotation_start, timezone.now().replace(microsecond=0)
)
custom_shift.start_rotation_from_user_index = update_shift.start_rotation_from_user_index
update_shift.until = custom_shift.rotation_start
extra_shifts.append(update_shift)
else:
# only reuse PK for preview when updating a rotation that won't be started after the update
custom_shift.public_primary_key = updated_shift_pk
qs = qs.exclude(public_primary_key=updated_shift_pk)
# only reuse PK for preview when updating a rotation that won't be started after the update
custom_shift.public_primary_key = updated_shift_pk
qs = qs.exclude(public_primary_key=updated_shift_pk)
ical_file = self._generate_ical_file_from_shifts(qs, extra_shifts=extra_shifts, allow_empty_users=True)

View file

@ -1588,6 +1588,52 @@ def test_delete_shift(make_organization_and_user, make_schedule, make_on_call_sh
assert on_call_shift.until is not None
@pytest.mark.django_db
def test_delete_shift_updates_linked_shift(
make_organization_and_user, make_user_for_organization, make_schedule, make_on_call_shift
):
organization, user_1 = make_organization_and_user()
other_user = make_user_for_organization(organization)
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
start_date = (timezone.now() - timezone.timedelta(days=7)).replace(microsecond=0)
updated_shifts = (
(start_date, 3600, user_1),
(start_date, 3600 * 2, user_1),
(start_date, 3600, other_user),
)
shifts = []
previous_shift = None
for start_date, duration, user in reversed(updated_shifts):
data = {
"priority_level": 1,
"start": start_date,
"rotation_start": start_date,
"duration": timezone.timedelta(seconds=duration),
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"schedule": schedule,
"updated_shift": previous_shift,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
on_call_shift.add_rolling_users([[user]])
previous_shift = on_call_shift
shifts.append(on_call_shift)
last_shift, intermediate_shift, first_shift = shifts
intermediate_shift.delete(force=True)
# deleted shift does not exist
with pytest.raises(CustomOnCallShift.DoesNotExist):
intermediate_shift.refresh_from_db()
# first shift now is linked to the following one
first_shift.refresh_from_db()
assert first_shift.updated_shift == last_shift
@pytest.mark.django_db
@pytest.mark.parametrize(
"starting_day,duration,deleted",
@ -1666,3 +1712,80 @@ def test_until_rrule_must_be_utc(
expected_rrule = f"RRULE:FREQ=WEEKLY;UNTIL={ical_rrule_until}Z;INTERVAL=4;WKST=SU"
assert expected_rrule in ical_data
@pytest.mark.django_db
def test_week_start_changed_daily_shift(
make_organization_and_user,
make_schedule,
make_on_call_shift,
):
organization, user_1 = make_organization_and_user()
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, time_zone="Europe/Warsaw")
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
today_weekday = now.weekday()
last_sunday = now - timezone.timedelta(days=7 + (today_weekday + 1) % 7)
last_saturday = last_sunday - timezone.timedelta(days=1)
# set week start to Sunday, so first event should be on last_sunday itself
data = {
"priority_level": 1,
"start": last_saturday,
"rotation_start": last_sunday,
"duration": timezone.timedelta(seconds=3600),
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"by_day": ["MO", "SU"],
"week_start": 5, # SU
"interval": 1,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
rolling_users = [[user_1]]
on_call_shift.add_rolling_users(rolling_users)
ical_data = on_call_shift.convert_to_ical()
expected_start = "DTSTART;VALUE=DATE-TIME:{}T000000Z".format(last_sunday.strftime("%Y%m%d"))
assert expected_start in ical_data
@pytest.mark.django_db
def test_week_start_changed_daily_shift_until(
make_organization_and_user,
make_schedule,
make_on_call_shift,
):
organization, user_1 = make_organization_and_user()
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb, time_zone="Europe/Warsaw")
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
today_weekday = now.weekday()
last_sunday = now - timezone.timedelta(days=7 + (today_weekday + 1) % 7)
last_saturday = last_sunday - timezone.timedelta(days=1)
thursday = last_sunday + timezone.timedelta(days=4)
data = {
"priority_level": 1,
"start": last_saturday,
"rotation_start": last_sunday,
"duration": timezone.timedelta(seconds=3600),
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
"by_day": ["MO", "SU"],
"week_start": 5, # SU
"interval": 1,
"until": thursday,
"schedule": schedule,
}
on_call_shift = make_on_call_shift(
organization=organization, shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT, **data
)
rolling_users = [[user_1]]
on_call_shift.add_rolling_users(rolling_users)
ical_data = on_call_shift.convert_to_ical()
# setting UNTIL to Thursday was generating extra events for current week Wednesday and Thursday
unexpected_by_days = ("BYDAY=WE", "BYDAY=TH")
for unexpected in unexpected_by_days:
assert unexpected not in ical_data

View file

@ -1744,3 +1744,39 @@ def test_refresh_ical_final_schedule_all_day_date_event(
calendar = icalendar.Calendar.from_ical(schedule.cached_ical_final_schedule)
events = [component for component in calendar.walk() if component.name == ICAL_COMPONENT_VEVENT]
assert len(events) == 0
@pytest.mark.django_db
def test_event_until_non_utc(make_organization, make_schedule):
organization = make_organization()
cached_ical_primary_schedule = textwrap.dedent(
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:testing
CALSCALE:GREGORIAN
BEGIN:VEVENT
CREATED:20220316T121102Z
LAST-MODIFIED:20230127T151619Z
DTSTAMP:20230127T151619Z
UID:something
SUMMARY:testing
RRULE:FREQ=WEEKLY;UNTIL=20221231T010101
DTSTART;TZID=Europe/Madrid:20220309T130000
DTEND;TZID=Europe/Madrid:20220309T133000
SEQUENCE:4
END:VEVENT
END:VCALENDAR
"""
)
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleICal,
cached_ical_file_primary=cached_ical_primary_schedule,
)
now = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
# check this works without raising exception
schedule.final_events("UTC", now, days=7)

View file

@ -106,7 +106,7 @@ def make_request(webhook, alert_group, data):
"url": None,
"request_trigger": None,
"request_headers": None,
"request_data": data,
"request_data": None,
"status_code": None,
"content": None,
"webhook": webhook,

34
engine/common/l10n.py Normal file
View file

@ -0,0 +1,34 @@
import logging
import typing
from babel.core import UnknownLocaleError
from babel.dates import format_datetime, format_time
from django.utils import timezone
logger = logging.getLogger(__name__)
FALLBACK_LOCALE = "en"
def _format_dt(
func: typing.Callable[[timezone.datetime, typing.Optional[str]], str],
dt: timezone.datetime,
locale: typing.Optional[str],
) -> str:
format = "short"
try:
# can't pass in locale of None otherwise TypeError is raised
return func(dt, format=format, locale=locale if locale else FALLBACK_LOCALE)
except UnknownLocaleError:
logger.warning(
f"babel.core.UnknownLocaleError encountered, locale={locale}. Will retry with fallback locale of {FALLBACK_LOCALE}"
)
return func(dt, format=format, locale=FALLBACK_LOCALE)
def format_localized_datetime(dt: timezone.datetime, locale: typing.Optional[str]) -> str:
return _format_dt(format_datetime, dt, locale)
def format_localized_time(dt: timezone.datetime, locale: typing.Optional[str]) -> str:
return _format_dt(format_time, dt, locale)

View file

@ -0,0 +1,27 @@
from django.utils import timezone
from common import l10n
REAL_LOCALE = "fr_CA"
FAKE_LOCALE = "potato"
dt = timezone.datetime(2022, 5, 4, 15, 14, 13, 12)
def test_format_localized_datetime():
assert l10n.format_localized_datetime(dt, REAL_LOCALE) == "2022-05-04 15 h 14"
# test that it catches the exception and falls back to some configured default
assert l10n.format_localized_datetime(dt, FAKE_LOCALE) == "5/4/22, 3:14\u202fPM"
# test that it properly handles None and falls back to some configured default
assert l10n.format_localized_datetime(dt, None) == "5/4/22, 3:14\u202fPM"
def test_format_localized_time():
assert l10n.format_localized_time(dt, REAL_LOCALE) == "15 h 14"
# test that it catches the exception and falls back to some configured default
assert l10n.format_localized_time(dt, FAKE_LOCALE) == "3:14\u202fPM"
# test that it properly handles None and falls back to some configured default
assert l10n.format_localized_time(dt, None) == "3:14\u202fPM"

View file

@ -53,3 +53,4 @@ requests==2.31.0
urllib3==1.26.15
prometheus_client==0.16.0
lxml==4.9.2
babel==2.12.1

View file

@ -1,144 +1,11 @@
import { TemplateForEdit, commonTemplateForEdit } from './CommonAlertTemplatesForm.config';
export interface Template {
name: string;
group: string;
}
export interface TemplateForEdit {
displayName: string;
name: string;
description?: string;
additionalData?: {
chatOpsName?: string;
data?: string;
additionalDescription?: string;
};
isRoute?: boolean;
}
export const templateForEdit: { [id: string]: TemplateForEdit } = {
web_title_template: {
displayName: 'Web title',
name: 'web_title_template',
description:
'Same for: phone call, sms, mobile push, mobile app title, email title, slack title, ms teams title, telegram title.',
},
web_message_template: {
displayName: 'Web message',
name: 'web_message_template',
description:
'Same for: phone call, sms, mobile push, mobile app title, email title, slack title, ms teams title, telegram title.',
},
slack_title_template: {
name: 'slack_title_template',
displayName: 'Slack title',
description: '',
additionalData: {
chatOpsName: 'slack',
data: 'Click "Acknowledge" and then "Unacknowledge" in Slack to trigger re-rendering.',
},
},
sms_title_template: {
name: 'sms_title_template',
displayName: 'Sms title',
description: '',
},
phone_call_title_template: {
name: 'phone_call_title_template',
displayName: 'Phone call title',
description: '',
},
email_title_template: {
name: 'email_title_template',
displayName: 'Email title',
description: '',
},
telegram_title_template: {
name: 'telegram_title_template',
displayName: 'Telegram title',
description: '',
additionalData: {
chatOpsName: 'telegram',
},
},
slack_message_template: {
name: 'slack_message_template',
displayName: 'Slack message',
description: '',
additionalData: {
chatOpsName: 'slack',
data: 'Click "Acknowledge" and then "Unacknowledge" in Slack to trigger re-rendering.',
},
},
email_message_template: {
name: 'email_message_template',
displayName: 'Email message',
description: '',
},
telegram_message_template: {
name: 'telegram_message_template',
displayName: 'Telegram message',
description: '',
additionalData: {
chatOpsName: 'telegram',
},
},
slack_image_url_template: {
name: 'slack_image_url_template',
displayName: 'Slack image url',
description: '',
additionalData: {
chatOpsName: 'slack',
data: 'Click "Acknowledge" and then "Unacknowledge" in Slack to trigger re-rendering.',
},
},
web_image_url_template: {
name: 'web_image_url_template',
displayName: 'Web image url',
description:
'Same for: phone call, sms, mobile push, mobile app title, email title, slack title, ms teams title, telegram title.',
},
telegram_image_url_template: {
name: 'telegram_image_url_template',
displayName: 'Telegram image url',
description: '',
additionalData: {
chatOpsName: 'telegram',
},
},
grouping_id_template: {
name: 'grouping_id_template',
displayName: 'Grouping',
description:
'Reduce noise, minimize duplication with Alert Grouping, based on time, alert content, and even multiple features at the same time. Check the cheasheet to customize your template.',
},
acknowledge_condition_template: {
name: 'acknowledge_condition_template',
displayName: 'Acknowledge condition',
description: '',
},
resolve_condition_template: {
name: 'resolve_condition_template',
displayName: 'Resolve condition',
description:
'When monitoring systems return to normal, they can send "resolve" alerts. OnCall can use these signals to resolve alert groups accordingly.',
},
source_link_template: {
name: 'source_link_template',
displayName: 'Source link',
description: '',
},
route_template: {
name: 'route_template',
displayName: 'Routing',
description:
'Routes direct alerts to different escalation chains based on the content, such as severity or region.',
additionalData: {
additionalDescription: 'For an alert to be directed to this route, the template must evaluate to True.',
data: 'Selected Alert will be directed to this route',
},
isRoute: true,
},
};
export const templateForEdit: { [id: string]: TemplateForEdit } = commonTemplateForEdit;
export const templatesToRender: Template[] = [
{

View file

@ -0,0 +1,141 @@
export interface Template {
name: string;
group: string;
}
export interface TemplateForEdit {
displayName: string;
name: string;
description?: string;
additionalData?: {
chatOpsName?: string;
data?: string;
additionalDescription?: string;
};
isRoute?: boolean;
}
export const commonTemplateForEdit: { [id: string]: TemplateForEdit } = {
web_title_template: {
displayName: 'Web title',
name: 'web_title_template',
description: '',
},
web_message_template: {
displayName: 'Web message',
name: 'web_message_template',
description: '',
},
slack_title_template: {
name: 'slack_title_template',
displayName: 'Slack title',
description: '',
additionalData: {
chatOpsName: 'slack',
data: 'Click "Acknowledge" and then "Unacknowledge" in Slack to trigger re-rendering.',
},
},
sms_title_template: {
name: 'sms_title_template',
displayName: 'Sms title',
description: '',
},
phone_call_title_template: {
name: 'phone_call_title_template',
displayName: 'Phone call title',
description: '',
},
email_title_template: {
name: 'email_title_template',
displayName: 'Email title',
description: '',
},
telegram_title_template: {
name: 'telegram_title_template',
displayName: 'Telegram title',
description: '',
additionalData: {
chatOpsName: 'telegram',
},
},
slack_message_template: {
name: 'slack_message_template',
displayName: 'Slack message',
description: '',
additionalData: {
chatOpsName: 'slack',
data: 'Click "Acknowledge" and then "Unacknowledge" in Slack to trigger re-rendering.',
},
},
email_message_template: {
name: 'email_message_template',
displayName: 'Email message',
description: '',
},
telegram_message_template: {
name: 'telegram_message_template',
displayName: 'Telegram message',
description: '',
additionalData: {
chatOpsName: 'telegram',
},
},
slack_image_url_template: {
name: 'slack_image_url_template',
displayName: 'Slack image url',
description: '',
additionalData: {
chatOpsName: 'slack',
data: 'Click "Acknowledge" and then "Unacknowledge" in Slack to trigger re-rendering.',
},
},
web_image_url_template: {
name: 'web_image_url_template',
displayName: 'Web image url',
description: '',
},
telegram_image_url_template: {
name: 'telegram_image_url_template',
displayName: 'Telegram image url',
description: '',
additionalData: {
chatOpsName: 'telegram',
},
},
grouping_id_template: {
name: 'grouping_id_template',
displayName: 'Grouping',
description:
'Reduce noise, minimize duplication with Alert Grouping, based on time, alert content, and even multiple features at the same time. Check the cheasheet to customize your template.',
additionalData: {
additionalDescription: 'Alerts with this Grouping ID are grouped together',
},
},
acknowledge_condition_template: {
name: 'acknowledge_condition_template',
displayName: 'Acknowledge condition',
description: '',
},
resolve_condition_template: {
name: 'resolve_condition_template',
displayName: 'Resolve condition',
description:
'When monitoring systems return to normal, they can send "resolve" alerts. If Autoresolution Template is True, the alert will resolve its group as "resolved by source". If the group is already resolved, the alert will be added to that group',
},
source_link_template: {
name: 'source_link_template',
displayName: 'Source link',
description: '',
},
route_template: {
name: 'route_template',
displayName: 'Routing',
description:
'Routes direct alerts to different escalation chains based on the content, such as severity or region.',
additionalData: {
additionalDescription: 'For an alert to be directed to this route, the template must evaluate to True.',
data: 'Selected Alert will be directed to this route',
},
isRoute: true,
},
};

View file

@ -14,13 +14,13 @@ export interface CheatSheetInterface {
export const groupingTemplateCheatSheet: CheatSheetInterface = {
name: 'Grouping template cheatsheet',
description: 'Jinja2 is used for templating ( docs). ',
description:
'Template is powered by Jinja2 and Markdown.\n Grouping template is used to extract key from the alert payload and group alerts based on that key. The key can combine different variables based on content and time. See examples below. ',
fields: [
{
name: 'Additional variables and functions',
listItems: [
{ listItemName: 'time(), datetimeformat, iso8601_to_time' },
{ listItemName: 'to_pretty_json' },
{ listItemName: 'regex_replace, regex_match' },
],
},
@ -28,20 +28,19 @@ export const groupingTemplateCheatSheet: CheatSheetInterface = {
name: 'Examples',
listItems: [
{ listItemName: 'group every hour', codeExample: '{{ time() | datetimeformat("%d-%m-%Y %H") }}' },
{ listItemName: 'group every X hours', codeExample: '{{ every_hour(5) }}' },
{ listItemName: 'group alerts every microsecond (every 0.000001 second)', codeExample: '{{ time() }}' },
{ listItemName: 'group based on the specific field', codeExample: '{{ payload.uuid }}' },
{ listItemName: 'group based on multiple fields', codeExample: '{{ payload.uuid }} \n {{ payload.region }}' },
{ listItemName: 'group based on multiple fields', codeExample: '{{ payload.uuid }}-{{ payload.region }}' },
{
listItemName: 'group alerts with the same uuid, create new group every hour',
codeExample: '{{ payload.uuid }} \n {{ time() | datetimeformat("%d-%m-%Y %H") }}',
codeExample: '{{ payload.uuid }}-{{ time() | datetimeformat("%d-%m-%Y %H") }}',
},
],
},
],
};
export const webTitleTemplateCheatSheet: CheatSheetInterface = {
export const genericTemplateCheatSheet: CheatSheetInterface = {
name: 'Web title template cheatsheet',
description: 'Jinja2 is used for templating (docs). \n Markdown is used for markup',
fields: [

View file

@ -15,7 +15,7 @@ export interface IntegrationCollapsibleItem {
expandedView: () => React.ReactNode; // for consistency, this is also a function
isCollapsible: boolean;
isExpanded?: boolean;
onStateChange?(): void;
onStateChange?(isChecked: boolean): void;
}
interface IntegrationCollapsibleTreeViewProps {
@ -41,7 +41,7 @@ const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewPro
<IntegrationCollapsibleTreeItem
item={it}
key={`${idx}-${innerIdx}`}
onClick={() => expandOrCollapseAtPos(idx, innerIdx)}
onClick={() => expandOrCollapseAtPos(!expandedList[idx][innerIdx], idx, innerIdx)}
isExpanded={expandedList[idx][innerIdx]}
/>
));
@ -51,7 +51,7 @@ const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewPro
<IntegrationCollapsibleTreeItem
item={item}
key={idx}
onClick={() => expandOrCollapseAtPos(idx)}
onClick={() => expandOrCollapseAtPos(expandedList[idx] as boolean, idx)}
isExpanded={expandedList[idx] as boolean}
/>
);
@ -72,16 +72,16 @@ const IntegrationCollapsibleTreeView: React.FC<IntegrationCollapsibleTreeViewPro
return expandedArrayValues;
}
function expandOrCollapseAtPos(i: number, j: number = undefined) {
if (j) {
function expandOrCollapseAtPos(isChecked: boolean, i: number, j: number = undefined) {
if (j !== undefined) {
let elem = configElements[i] as IntegrationCollapsibleItem[];
if (elem[j].onStateChange) {
elem[j].onStateChange();
elem[j].onStateChange(isChecked);
}
} else {
let elem = configElements[i] as IntegrationCollapsibleItem;
if (elem.onStateChange) {
elem.onStateChange();
elem.onStateChange(isChecked);
}
}

View file

@ -5,7 +5,7 @@ import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
import { TemplateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesForm.config';
import Block from 'components/GBlock/Block';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
import Text from 'components/Text/Text';

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { ConfirmModal, HorizontalGroup, Icon, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
@ -15,6 +15,7 @@ import { ChannelFilter } from 'models/channel_filter';
import CommonIntegrationHelper from 'pages/integration_2/CommonIntegration2.helper';
import IntegrationHelper from 'pages/integration_2/Integration2.helper';
import { useStore } from 'state/useStore';
import { openNotification } from 'utils';
const cx = cn.bind(styles);
@ -28,13 +29,9 @@ interface CollapsedIntegrationRouteDisplayProps {
const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDisplayProps> = observer(
({ channelFilterId, alertReceiveChannelId, routeIndex, toggle }) => {
const store = useStore();
const { escalationChainStore, alertReceiveChannelStore, telegramChannelStore } = store;
const { escalationChainStore, alertReceiveChannelStore } = store;
const [routeIdForDeletion, setRouteIdForDeletion] = useState<ChannelFilter['id']>(undefined);
useEffect(() => {
telegramChannelStore.updateItems();
}, [channelFilterId]);
const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId];
if (!channelFilter) {
return null;
@ -162,6 +159,7 @@ const CollapsedIntegrationRouteDisplay: React.FC<CollapsedIntegrationRouteDispla
async function onRouteDeleteConfirm() {
setRouteIdForDeletion(undefined);
await alertReceiveChannelStore.deleteChannelFilter(routeIdForDeletion);
openNotification('Route has been deleted');
}
}
);

View file

@ -276,6 +276,7 @@ const ExpandedIntegrationRouteDisplay: React.FC<ExpandedIntegrationRouteDisplayP
async function onRouteDeleteConfirm() {
setState({ routeIdForDeletion: undefined });
await alertReceiveChannelStore.deleteChannelFilter(routeIdForDeletion);
openNotification('Route has been deleted');
}
function onEscalationChainChange({ value }) {

View file

@ -27,7 +27,7 @@ export const commonTemplatesToRender: TemplateBlock[] = [
name: 'resolve_condition_template',
label: 'Autoresolution',
labelTooltip:
'If Autoresolution Template is True, the alert will resolve its group as "resolved by source". If the group is already resolved, the alert will be added to that group.',
'If Autoresolution Template is True, the alert will resolve its group as "resolved by source". If the group is already resolved, the alert will be added to that group',
height: MONACO_INPUT_HEIGHT_SMALL,
},
],

View file

@ -68,8 +68,8 @@ const IntegrationTemplateList: React.FC<IntegrationTemplateListProps> = ({
<IntegrationBlockItem>
<Text type="secondary">
Templates are used to interpret alert from monitoring. Reduce noise by grouping, set auto-resolution,
customize visualization and notifications by extracting data from alert.
Set templates to interpret monitoring alerts and minimize noise. Group alerts, enable auto-resolution,
customize visualizations and notifications by extracting data from alerts.
</Text>
</IntegrationBlockItem>

View file

@ -5,12 +5,12 @@ import cn from 'classnames/bind';
import { debounce } from 'lodash-es';
import { observer } from 'mobx-react';
import { TemplateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesForm.config';
import CheatSheet from 'components/CheatSheet/CheatSheet';
import {
groupingTemplateCheatSheet,
slackMessageTemplateCheatSheet,
webTitleTemplateCheatSheet,
genericTemplateCheatSheet,
} from 'components/CheatSheet/CheatSheet.config';
import Block from 'components/GBlock/Block';
import MonacoEditor from 'components/MonacoEditor/MonacoEditor';
@ -45,7 +45,7 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
const { id, onHide, template, onUpdateTemplates, onUpdateRoute, templateBody, channelFilterId, templates } = props;
const [isCheatSheetVisible, setIsCheatSheetVisible] = useState<boolean>(false);
const [chatOps, setChatOps] = useState(undefined);
const [chatOpsPermalink, setChatOpsPermalink] = useState(undefined);
const [alertGroupPayload, setAlertGroupPayload] = useState<JSON>(undefined);
const [changedTemplateBody, setChangedTemplateBody] = useState<string>(templateBody);
const [resultError, setResultError] = useState<string>(undefined);
@ -102,10 +102,8 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
const onSelectAlertGroup = useCallback((alertGroup: Alert) => {
if (template.additionalData?.chatOpsName) {
setChatOps({
setChatOpsPermalink({
permalink: alertGroup?.permalinks[template.additionalData?.chatOpsName],
name: template.additionalData?.chatOpsName,
comment: template.additionalData?.data,
});
}
}, []);
@ -136,7 +134,7 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
case 'Web title':
case 'Web message':
case 'Web image':
return webTitleTemplateCheatSheet;
return genericTemplateCheatSheet;
case 'Auto acknowledge':
case 'Source link':
case 'Phone call':
@ -151,7 +149,7 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
case 'Email message':
return slackMessageTemplateCheatSheet;
default:
return webTitleTemplateCheatSheet;
return genericTemplateCheatSheet;
}
};
return (
@ -223,10 +221,10 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
)}
<Result
alertReceiveChannelId={id}
templateName={template.name}
template={template}
templateBody={changedTemplateBody}
alertGroup={undefined}
chatOps={chatOps}
chatOpsPermalink={chatOpsPermalink}
payload={alertGroupPayload}
error={resultError}
onSaveAndFollowLink={onSaveAndFollowLink}
@ -239,17 +237,19 @@ const IntegrationTemplate = observer((props: IntegrationTemplateProps) => {
interface ResultProps {
alertReceiveChannelId: AlertReceiveChannel['id'];
templateName: string;
// templateName: string;
templateBody: string;
template: TemplateForEdit;
alertGroup?: Alert;
chatOps?: { permalink: string; name: string; comment?: string };
chatOpsPermalink?: string;
payload?: JSON;
error?: string;
onSaveAndFollowLink?: (link: string) => void;
}
const Result = (props: ResultProps) => {
const { alertReceiveChannelId, templateName, chatOps, payload, templateBody, error, onSaveAndFollowLink } = props;
const { alertReceiveChannelId, template, templateBody, chatOpsPermalink, payload, error, onSaveAndFollowLink } =
props;
const getCapitalizedChatopsName = (name: string) => {
return name.charAt(0).toUpperCase() + name.slice(1);
@ -271,8 +271,8 @@ const Result = (props: ResultProps) => {
) : (
<Block bordered fullWidth withBackground>
<TemplatePreview
key={templateName}
templateName={templateName}
key={template.name}
templateName={template.name}
templateBody={templateBody}
alertReceiveChannelId={alertReceiveChannelId}
payload={payload}
@ -280,27 +280,27 @@ const Result = (props: ResultProps) => {
</Block>
)}
{chatOps && (
{template?.additionalData?.additionalDescription && (
<Text type="secondary">{template?.additionalData.additionalDescription}</Text>
)}
{template?.additionalData?.chatOpsName && (
<VerticalGroup>
<Button onClick={() => onSaveAndFollowLink(chatOps.permalink)}>
<Button onClick={() => onSaveAndFollowLink(chatOpsPermalink)}>
<HorizontalGroup spacing="xs" align="center">
Save and open Alert Group in {getCapitalizedChatopsName(chatOps.name)}{' '}
Save and open Alert Group in {getCapitalizedChatopsName(template.additionalData.chatOpsName)}{' '}
<Icon name="external-link-alt" />
</HorizontalGroup>
</Button>
{chatOps.comment && (
<Text type="secondary">
Click "Acknowledge" and then "Unacknowledge" in Slack to trigger re-rendering.
</Text>
)}
{template.additionalData.data && <Text type="secondary">{template.additionalData.data}</Text>}
</VerticalGroup>
)}
</VerticalGroup>
) : (
<div>
<Block bordered fullWidth className={cx('block-style')}>
<Text>You do not have any input data to render result. Please select Alert group to see end result</Text>
<Text> Select alert group or "Use custom payload"</Text>
</Block>
</div>
)}

View file

@ -35,7 +35,7 @@ export const form: { name: string; fields: FormItem[] } = {
name: 'team',
label: 'Assign to Team',
description:
'Assigning to the teams allows you to filter Outgoing Webhooks and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details',
'Assigning to the teams allows you to filter Outgoing Webhooks and configure their visibility. Go to OnCall -> Settings -> Team and Access Settings for more details. This setting does not effect execution of the webhook.',
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
@ -48,6 +48,7 @@ export const form: { name: string; fields: FormItem[] } = {
{
name: 'trigger_type',
label: 'Trigger Type',
description: 'The type of event which will cause this webhook to execute.',
type: FormItemType.Select,
extra: {
options: [
@ -135,7 +136,7 @@ export const form: { name: string; fields: FormItem[] } = {
},
validation: { required: true },
description:
'Integrations that this webhook applies to. If this is empty the webhook will apply to all integrations',
'Integrations that this webhook applies to. If this is empty the webhook will execute for all integrations',
},
{
name: 'url',
@ -146,6 +147,7 @@ export const form: { name: string; fields: FormItem[] } = {
{
name: 'headers',
label: 'Webhook Headers',
description: 'Request headers should be in JSON format.',
type: FormItemType.TextArea,
extra: {
rows: 3,
@ -161,6 +163,8 @@ export const form: { name: string; fields: FormItem[] } = {
},
{
name: 'authorization_header',
description:
'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456',
type: FormItemType.Password,
},
{
@ -176,13 +180,14 @@ export const form: { name: string; fields: FormItem[] } = {
name: 'forward_all',
normalize: (value) => Boolean(value),
type: FormItemType.Switch,
description: "Forwards whole payload of the alert to the webhook's url as POST/PUT data",
description: "Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data",
},
{
name: 'data',
getDisabled: (data) => Boolean(data?.forward_all),
type: FormItemType.TextArea,
description: 'Available variables: {{ alert_payload }}, {{ alert_group_id }}',
description:
'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}',
extra: {
rows: 9,
},

View file

@ -22,7 +22,8 @@ import Emoji from 'react-emoji-render';
import { RouteComponentProps, useHistory, withRouter } from 'react-router-dom';
import { debounce } from 'throttle-debounce';
import { TemplateForEdit, templateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
import { templateForEdit } from 'components/AlertTemplates/AlertTemplatesForm.config';
import { TemplateForEdit } from 'components/AlertTemplates/CommonAlertTemplatesForm.config';
import HamburgerMenu from 'components/HamburgerMenu/HamburgerMenu';
import IntegrationCollapsibleTreeView, {
IntegrationCollapsibleItem,
@ -60,6 +61,7 @@ import { MaintenanceType } from 'models/maintenance/maintenance.types';
import { INTEGRATION_TEMPLATES_LIST, MONACO_PAYLOAD_OPTIONS } from 'pages/integration_2/Integration2.config';
import IntegrationHelper from 'pages/integration_2/Integration2.helper';
import styles from 'pages/integration_2/Integration2.module.scss';
import { AppFeature } from 'state/features';
import { PageProps, SelectOption, WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
@ -81,8 +83,8 @@ interface Integration2State extends PageBaseState {
isEditRegexpRouteTemplateModalOpen: boolean;
channelFilterIdForEdit: ChannelFilter['id'];
isTemplateSettingsOpen: boolean;
newRoutes: string[];
isAddingRoute: boolean;
openRoutes: string[];
}
const ACTIONS_LIST_WIDTH = 200;
@ -102,8 +104,8 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
isEditRegexpRouteTemplateModalOpen: false,
channelFilterIdForEdit: undefined,
isTemplateSettingsOpen: false,
newRoutes: [],
isAddingRoute: false,
openRoutes: [],
};
}
@ -116,9 +118,15 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
} = this.props;
const {
store: { alertReceiveChannelStore },
store,
store: { alertReceiveChannelStore, telegramChannelStore },
} = this.props;
if (store.hasFeature(AppFeature.Telegram)) {
// workaround until we get the whole telegram data in response
telegramChannelStore.updateItems();
}
if (query?.template) {
this.openEditTemplateModal(query.template, query.routeId && query.routeId);
}
@ -416,7 +424,10 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
filtering_term_type: 1, // non-regex
})
.then(async (channelFilter: ChannelFilter) => {
this.setState({ isAddingRoute: false, newRoutes: this.state.newRoutes.concat(channelFilter.id) });
this.setState((prevState) => ({
isAddingRoute: false,
openRoutes: prevState.openRoutes.concat(channelFilter.id),
}));
await alertReceiveChannelStore.updateChannelFilters(id, true);
await escalationPolicyStore.updateEscalationPolicies(channelFilter.escalation_chain);
openNotification('A new route has been added');
@ -439,6 +450,8 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
},
} = this.props;
const { openRoutes } = this.state;
const templates = alertReceiveChannelStore.templates[id];
const channelFilterIds = alertReceiveChannelStore.channelFilterIds[id];
@ -447,13 +460,14 @@ class Integration2 extends React.Component<Integration2Props, Integration2State>
({
canHoverIcon: true,
isCollapsible: true,
// this will keep new routes expanded at the very first time
isExpanded: this.state.newRoutes.indexOf(channelFilterId) > -1 ? true : false,
onStateChange: () => {
if (this.state.newRoutes.indexOf(channelFilterId) > -1) {
// this will close them on user action
this.setState((prevState) => ({ newRoutes: prevState.newRoutes.filter((r) => r !== channelFilterId) }));
}
isExpanded: openRoutes.indexOf(channelFilterId) > -1,
onStateChange: (isChecked: boolean) => {
const newOpenRoutes = [...openRoutes];
this.setState({
openRoutes: isChecked
? newOpenRoutes.concat(channelFilterId)
: newOpenRoutes.filter((filterId) => filterId !== channelFilterId),
});
},
collapsedView: (toggle) => (
<CollapsedIntegrationRouteDisplay

View file

@ -177,7 +177,7 @@ tests:
name: RABBITMQ_PASSWORD
valueFrom:
secretKeyRef:
key: password
key: rabbitmq-password
name: oncall-rabbitmq-external
- containsDocument:
kind: Secret

View file

@ -320,7 +320,7 @@ externalRabbitmq:
# use an existing secret for the rabbitmq password
existingSecret: ""
# the key in the secret containing the rabbitmq password
passwordKey: password
passwordKey: ""
# the key in the secret containing the rabbitmq username
usernameKey: username