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:
parent
4b6e80b27a
commit
5d035808bb
49 changed files with 1148 additions and 483 deletions
108
.github/ISSUE_TEMPLATE/0-bug-report-template.yml
vendored
Normal file
108
.github/ISSUE_TEMPLATE/0-bug-report-template.yml
vendored
Normal 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
|
||||
48
.github/ISSUE_TEMPLATE/1-feature-request-template.yml
vendored
Normal file
48
.github/ISSUE_TEMPLATE/1-feature-request-template.yml
vendored
Normal 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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
blank_issues_enabled: false
|
||||
24
.github/ISSUE_TEMPLATE/issue-template.md
vendored
24
.github/ISSUE_TEMPLATE/issue-template.md
vendored
|
|
@ -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>`
|
||||
2
.github/workflows/linting-and-tests.yml
vendored
2
.github/workflows/linting-and-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
62
.github/workflows/on-issue-creation.yml
vendored
62
.github/workflows/on-issue-creation.yml
vendored
|
|
@ -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' || '' }}
|
||||
|
|
|
|||
15
.github/workflows/on-issue-labeled.yml
vendored
15
.github/workflows/on-issue-labeled.yml
vendored
|
|
@ -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
|
||||
26
.github/workflows/triage-unlabeled-issues.yml
vendored
26
.github/workflows/triage-unlabeled-issues.yml
vendored
|
|
@ -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!
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -22,4 +22,5 @@ class MobileAppUserSettingsSerializer(serializers.ModelSerializer):
|
|||
"important_notification_override_dnd",
|
||||
"info_notifications_enabled",
|
||||
"going_oncall_notification_timing",
|
||||
"locale",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
39
engine/apps/schedules/migrations/0013_auto_20230517_0510.py
Normal file
39
engine/apps/schedules/migrations/0013_auto_20230517_0510.py
Normal 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),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
34
engine/common/l10n.py
Normal 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)
|
||||
27
engine/common/tests/test_l10n.py
Normal file
27
engine/common/tests/test_l10n.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ tests:
|
|||
name: RABBITMQ_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
key: password
|
||||
key: rabbitmq-password
|
||||
name: oncall-rabbitmq-external
|
||||
- containsDocument:
|
||||
kind: Secret
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue