From bd36370e14600f30a76e6cbc5f7b3aa4c0d39afe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Jun 2023 10:25:29 -0400 Subject: [PATCH 1/3] Merge: Release oncall Helm chart 1.2.42 (#2163) Merge this PR to `main` branch to start another [github actions job](https://github.com/grafana/oncall/blob/dev/.github/workflows/helm_release.yml) that will release the updated version of the chart (version: 1.2.42, appVersion: v1.2.42) into `grafana/helm-charts` helm repository. This PR was created automatically by this [github action](https://github.com/grafana/oncall/blob/dev/.github/workflows/helm_release_pr.yml). Co-authored-by: GitHub Actions --- helm/oncall/Chart.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/helm/oncall/Chart.yaml b/helm/oncall/Chart.yaml index e2afac05..828becab 100644 --- a/helm/oncall/Chart.yaml +++ b/helm/oncall/Chart.yaml @@ -2,9 +2,8 @@ apiVersion: v2 name: oncall description: Developer-friendly incident response with brilliant Slack integration type: application -# version and appVersion are handled by CI, no need to change them manually -version: 1.2.41 -appVersion: v1.2.41 +version: 1.2.42 +appVersion: v1.2.42 dependencies: - name: cert-manager version: v1.8.0 From 06aa12244ae2f743f2ba11c073f64cc6acecdd3c Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 14 Jun 2023 13:37:08 +0000 Subject: [PATCH 2/3] Release oncall Helm chart 1.2.44 --- helm/oncall/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helm/oncall/Chart.yaml b/helm/oncall/Chart.yaml index 828becab..787ee2d8 100644 --- a/helm/oncall/Chart.yaml +++ b/helm/oncall/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: oncall description: Developer-friendly incident response with brilliant Slack integration type: application -version: 1.2.42 -appVersion: v1.2.42 +version: 1.2.44 +appVersion: v1.2.44 dependencies: - name: cert-manager version: v1.8.0 From 5d035808bb4aa5e115722d2cd0ec99ea0c191d6b Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Mon, 19 Jun 2023 13:52:34 +0800 Subject: [PATCH 3/3] Dev to main (#2282) Co-authored-by: Michael Derynck Co-authored-by: Joey Orlando Co-authored-by: Matias Bordese Co-authored-by: Rares Mardare Co-authored-by: Joey Orlando Co-authored-by: Ildar Iskhakov Co-authored-by: Ruslan Gainanov Co-authored-by: Yulia Shanyrova --- .../ISSUE_TEMPLATE/0-bug-report-template.yml | 108 ++++++++++ .../1-feature-request-template.yml | 48 +++++ .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/issue-template.md | 24 --- .github/workflows/linting-and-tests.yml | 2 + .github/workflows/on-issue-creation.yml | 62 +++--- .github/workflows/on-issue-labeled.yml | 15 -- .github/workflows/triage-unlabeled-issues.yml | 26 --- CHANGELOG.md | 18 ++ engine/apps/alerts/models/alert_group.py | 8 + ...resolve_alert_group_by_source_if_needed.py | 11 +- engine/apps/alerts/tests/test_alert_group.py | 23 ++ engine/apps/api/permissions.py | 27 --- engine/apps/api/serializers/channel_filter.py | 21 +- engine/apps/api/serializers/on_call_shifts.py | 16 +- engine/apps/api/serializers/user.py | 12 +- engine/apps/api/tests/test_oncall_shift.py | 116 +++++----- engine/apps/api/tests/test_user.py | 10 +- .../0008_mobileappusersettings_locale.py | 18 ++ engine/apps/mobile_app/models.py | 2 + engine/apps/mobile_app/serializers.py | 1 + engine/apps/mobile_app/tasks.py | 34 ++- .../mobile_app/tests/test_user_settings.py | 41 ++++ .../test_your_going_oncall_notification.py | 199 ++++++++++++++++-- .../public_api/serializers/on_call_shifts.py | 13 -- .../amixr_recurring_ical_events_adapter.py | 23 ++ .../migrations/0013_auto_20230517_0510.py | 39 ++++ .../schedules/models/custom_on_call_shift.py | 44 ++-- .../apps/schedules/models/on_call_schedule.py | 19 +- .../tests/test_custom_on_call_shift.py | 123 +++++++++++ .../schedules/tests/test_on_call_schedule.py | 36 ++++ engine/apps/webhooks/tasks/trigger_webhook.py | 2 +- engine/common/l10n.py | 34 +++ engine/common/tests/test_l10n.py | 27 +++ engine/requirements.txt | 1 + .../AlertTemplatesForm.config.ts | 139 +----------- .../CommonAlertTemplatesForm.config.ts | 141 +++++++++++++ .../CheatSheet/CheatSheet.config.ts | 11 +- .../IntegrationCollapsibleTreeView.tsx | 14 +- .../EditRegexpRouteTemplateModal.tsx | 2 +- .../CollapsedIntegrationRouteDisplay.tsx | 10 +- .../ExpandedIntegrationRouteDisplay.tsx | 1 + .../IntegrationCommonTemplatesList.config.ts | 2 +- .../IntegrationTemplatesList.tsx | 4 +- .../IntegrationTemplate.tsx | 48 ++--- .../OutgoingWebhook2Form.config.tsx | 13 +- .../src/pages/integration_2/Integration2.tsx | 38 ++-- helm/oncall/tests/rabbitmq_env_test.yaml | 2 +- helm/oncall/values.yaml | 2 +- 49 files changed, 1148 insertions(+), 483 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/0-bug-report-template.yml create mode 100644 .github/ISSUE_TEMPLATE/1-feature-request-template.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/issue-template.md delete mode 100644 .github/workflows/on-issue-labeled.yml delete mode 100644 .github/workflows/triage-unlabeled-issues.yml create mode 100644 engine/apps/mobile_app/migrations/0008_mobileappusersettings_locale.py create mode 100644 engine/apps/schedules/migrations/0013_auto_20230517_0510.py create mode 100644 engine/common/l10n.py create mode 100644 engine/common/tests/test_l10n.py create mode 100644 grafana-plugin/src/components/AlertTemplates/CommonAlertTemplatesForm.config.ts diff --git a/.github/ISSUE_TEMPLATE/0-bug-report-template.yml b/.github/ISSUE_TEMPLATE/0-bug-report-template.yml new file mode 100644 index 00000000..aab0601a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/0-bug-report-template.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/1-feature-request-template.yml b/.github/ISSUE_TEMPLATE/1-feature-request-template.yml new file mode 100644 index 00000000..0618d7cc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-feature-request-template.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..3ba13e0c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md deleted file mode 100644 index f65de799..00000000 --- a/.github/ISSUE_TEMPLATE/issue-template.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: General Issue -about: General requirements to all issues. -title: Specific issue name -labels: "" -assignees: "" ---- - -`` - -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. - -`` diff --git a/.github/workflows/linting-and-tests.yml b/.github/workflows/linting-and-tests.yml index 1030320f..0cf29768 100644 --- a/.github/workflows/linting-and-tests.yml +++ b/.github/workflows/linting-and-tests.yml @@ -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 diff --git a/.github/workflows/on-issue-creation.yml b/.github/workflows/on-issue-creation.yml index a31a26cc..a196eb0f 100644 --- a/.github/workflows/on-issue-creation.yml +++ b/.github/workflows/on-issue-creation.yml @@ -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' || '' }} diff --git a/.github/workflows/on-issue-labeled.yml b/.github/workflows/on-issue-labeled.yml deleted file mode 100644 index 736a70d4..00000000 --- a/.github/workflows/on-issue-labeled.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/triage-unlabeled-issues.yml b/.github/workflows/triage-unlabeled-issues.yml deleted file mode 100644 index c572e9a5..00000000 --- a/.github/workflows/triage-unlabeled-issues.yml +++ /dev/null @@ -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! diff --git a/CHANGELOG.md b/CHANGELOG.md index ee689151..2e764beb 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index dcdc2f1d..50aa01aa 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -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): diff --git a/engine/apps/alerts/tasks/resolve_alert_group_by_source_if_needed.py b/engine/apps/alerts/tasks/resolve_alert_group_by_source_if_needed.py index 2a6de51a..ade38de9 100644 --- a/engine/apps/alerts/tasks/resolve_alert_group_by_source_if_needed.py +++ b/engine/apps/alerts/tasks/resolve_alert_group_by_source_if_needed.py @@ -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() diff --git a/engine/apps/alerts/tests/test_alert_group.py b/engine/apps/alerts/tests/test_alert_group.py index 0e4d5f68..d673567c 100644 --- a/engine/apps/alerts/tests/test_alert_group.py +++ b/engine/apps/alerts/tests/test_alert_group.py @@ -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 diff --git a/engine/apps/api/permissions.py b/engine/apps/api/permissions.py index a1bebd91..0d77ec8d 100644 --- a/engine/apps/api/permissions.py +++ b/engine/apps/api/permissions.py @@ -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, -} diff --git a/engine/apps/api/serializers/channel_filter.py b/engine/apps/api/serializers/channel_filter.py index 335a816b..d6053192 100644 --- a/engine/apps/api/serializers/channel_filter.py +++ b/engine/apps/api/serializers/channel_filter.py @@ -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") diff --git a/engine/apps/api/serializers/on_call_shifts.py b/engine/apps/api/serializers/on_call_shifts.py index 62ec52cb..98494d35 100644 --- a/engine/apps/api/serializers/on_call_shifts.py +++ b/engine/apps/api/serializers/on_call_shifts.py @@ -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 diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index 77d8cb9e..1164954f 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -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): diff --git a/engine/apps/api/tests/test_oncall_shift.py b/engine/apps/api/tests/test_oncall_shift.py index df2c6d8b..99879de3 100644 --- a/engine/apps/api/tests/test_oncall_shift.py +++ b/engine/apps/api/tests/test_oncall_shift.py @@ -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 = [ diff --git a/engine/apps/api/tests/test_user.py b/engine/apps/api/tests/test_user.py index 29eda039..ec30db63 100644 --- a/engine/apps/api/tests/test_user.py +++ b/engine/apps/api/tests/test_user.py @@ -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, diff --git a/engine/apps/mobile_app/migrations/0008_mobileappusersettings_locale.py b/engine/apps/mobile_app/migrations/0008_mobileappusersettings_locale.py new file mode 100644 index 00000000..f3fefa89 --- /dev/null +++ b/engine/apps/mobile_app/migrations/0008_mobileappusersettings_locale.py @@ -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), + ), + ] diff --git a/engine/apps/mobile_app/models.py b/engine/apps/mobile_app/models.py index 4fb7342f..7d45692e 100644 --- a/engine/apps/mobile_app/models.py +++ b/engine/apps/mobile_app/models.py @@ -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) diff --git a/engine/apps/mobile_app/serializers.py b/engine/apps/mobile_app/serializers.py index d10dcff9..93b72f62 100644 --- a/engine/apps/mobile_app/serializers.py +++ b/engine/apps/mobile_app/serializers.py @@ -22,4 +22,5 @@ class MobileAppUserSettingsSerializer(serializers.ModelSerializer): "important_notification_override_dnd", "info_notifications_enabled", "going_oncall_notification_timing", + "locale", ) diff --git a/engine/apps/mobile_app/tasks.py b/engine/apps/mobile_app/tasks.py index 29e2528d..60be966f 100644 --- a/engine/apps/mobile_app/tasks.py +++ b/engine/apps/mobile_app/tasks.py @@ -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) diff --git a/engine/apps/mobile_app/tests/test_user_settings.py b/engine/apps/mobile_app/tests/test_user_settings.py index 3f15fefa..043b0521 100644 --- a/engine/apps/mobile_app/tests/test_user_settings.py +++ b/engine/apps/mobile_app/tests/test_user_settings.py @@ -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} diff --git a/engine/apps/mobile_app/tests/test_your_going_oncall_notification.py b/engine/apps/mobile_app/tests/test_your_going_oncall_notification.py index 9cd99a03..586cc913 100644 --- a/engine/apps/mobile_app/tests/test_your_going_oncall_notification.py +++ b/engine/apps/mobile_app/tests/test_your_going_oncall_notification.py @@ -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 diff --git a/engine/apps/public_api/serializers/on_call_shifts.py b/engine/apps/public_api/serializers/on_call_shifts.py index 1a02d862..01f96466 100644 --- a/engine/apps/public_api/serializers/on_call_shifts.py +++ b/engine/apps/public_api/serializers/on_call_shifts.py @@ -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: diff --git a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py index 37bc56b0..873a399f 100644 --- a/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py +++ b/engine/apps/schedules/ical_events/adapter/amixr_recurring_ical_events_adapter.py @@ -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) diff --git a/engine/apps/schedules/migrations/0013_auto_20230517_0510.py b/engine/apps/schedules/migrations/0013_auto_20230517_0510.py new file mode 100644 index 00000000..d74b1c6f --- /dev/null +++ b/engine/apps/schedules/migrations/0013_auto_20230517_0510.py @@ -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), + ] diff --git a/engine/apps/schedules/models/custom_on_call_shift.py b/engine/apps/schedules/models/custom_on_call_shift.py index 0682e82d..26ecc5b1 100644 --- a/engine/apps/schedules/models/custom_on_call_shift.py +++ b/engine/apps/schedules/models/custom_on_call_shift.py @@ -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): diff --git a/engine/apps/schedules/models/on_call_schedule.py b/engine/apps/schedules/models/on_call_schedule.py index b1a4fd0a..043a0e21 100644 --- a/engine/apps/schedules/models/on_call_schedule.py +++ b/engine/apps/schedules/models/on_call_schedule.py @@ -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) diff --git a/engine/apps/schedules/tests/test_custom_on_call_shift.py b/engine/apps/schedules/tests/test_custom_on_call_shift.py index 74208240..b6c769b7 100644 --- a/engine/apps/schedules/tests/test_custom_on_call_shift.py +++ b/engine/apps/schedules/tests/test_custom_on_call_shift.py @@ -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 diff --git a/engine/apps/schedules/tests/test_on_call_schedule.py b/engine/apps/schedules/tests/test_on_call_schedule.py index 9e76c662..cf696be8 100644 --- a/engine/apps/schedules/tests/test_on_call_schedule.py +++ b/engine/apps/schedules/tests/test_on_call_schedule.py @@ -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) diff --git a/engine/apps/webhooks/tasks/trigger_webhook.py b/engine/apps/webhooks/tasks/trigger_webhook.py index c0e9e566..3073c2b6 100644 --- a/engine/apps/webhooks/tasks/trigger_webhook.py +++ b/engine/apps/webhooks/tasks/trigger_webhook.py @@ -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, diff --git a/engine/common/l10n.py b/engine/common/l10n.py new file mode 100644 index 00000000..998529d5 --- /dev/null +++ b/engine/common/l10n.py @@ -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) diff --git a/engine/common/tests/test_l10n.py b/engine/common/tests/test_l10n.py new file mode 100644 index 00000000..7f4e05f7 --- /dev/null +++ b/engine/common/tests/test_l10n.py @@ -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" diff --git a/engine/requirements.txt b/engine/requirements.txt index a6d595b4..3e4e4f22 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -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 diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts index 623eec63..450faa7b 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts @@ -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[] = [ { diff --git a/grafana-plugin/src/components/AlertTemplates/CommonAlertTemplatesForm.config.ts b/grafana-plugin/src/components/AlertTemplates/CommonAlertTemplatesForm.config.ts new file mode 100644 index 00000000..9760024f --- /dev/null +++ b/grafana-plugin/src/components/AlertTemplates/CommonAlertTemplatesForm.config.ts @@ -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, + }, +}; diff --git a/grafana-plugin/src/components/CheatSheet/CheatSheet.config.ts b/grafana-plugin/src/components/CheatSheet/CheatSheet.config.ts index 171d4ac1..e921f173 100644 --- a/grafana-plugin/src/components/CheatSheet/CheatSheet.config.ts +++ b/grafana-plugin/src/components/CheatSheet/CheatSheet.config.ts @@ -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: [ diff --git a/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx b/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx index 8f41ba1b..0375c963 100644 --- a/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx +++ b/grafana-plugin/src/components/IntegrationCollapsibleTreeView/IntegrationCollapsibleTreeView.tsx @@ -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 expandOrCollapseAtPos(idx, innerIdx)} + onClick={() => expandOrCollapseAtPos(!expandedList[idx][innerIdx], idx, innerIdx)} isExpanded={expandedList[idx][innerIdx]} /> )); @@ -51,7 +51,7 @@ const IntegrationCollapsibleTreeView: React.FC expandOrCollapseAtPos(idx)} + onClick={() => expandOrCollapseAtPos(expandedList[idx] as boolean, idx)} isExpanded={expandedList[idx] as boolean} /> ); @@ -72,16 +72,16 @@ const IntegrationCollapsibleTreeView: React.FC = observer( ({ channelFilterId, alertReceiveChannelId, routeIndex, toggle }) => { const store = useStore(); - const { escalationChainStore, alertReceiveChannelStore, telegramChannelStore } = store; + const { escalationChainStore, alertReceiveChannelStore } = store; const [routeIdForDeletion, setRouteIdForDeletion] = useState(undefined); - useEffect(() => { - telegramChannelStore.updateItems(); - }, [channelFilterId]); - const channelFilter = alertReceiveChannelStore.channelFilters[channelFilterId]; if (!channelFilter) { return null; @@ -162,6 +159,7 @@ const CollapsedIntegrationRouteDisplay: React.FC = ({ - 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. diff --git a/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx b/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx index 774f8eea..fd89fa8b 100644 --- a/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx +++ b/grafana-plugin/src/containers/IntegrationTemplate/IntegrationTemplate.tsx @@ -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(false); - const [chatOps, setChatOps] = useState(undefined); + const [chatOpsPermalink, setChatOpsPermalink] = useState(undefined); const [alertGroupPayload, setAlertGroupPayload] = useState(undefined); const [changedTemplateBody, setChangedTemplateBody] = useState(templateBody); const [resultError, setResultError] = useState(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) => { )} { 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) => { ) : ( { )} - {chatOps && ( + {template?.additionalData?.additionalDescription && ( + {template?.additionalData.additionalDescription} + )} + + {template?.additionalData?.chatOpsName && ( - - {chatOps.comment && ( - - Click "Acknowledge" and then "Unacknowledge" in Slack to trigger re-rendering. - - )} + {template.additionalData.data && {template.additionalData.data}} )} ) : (
- You do not have any input data to render result. Please select Alert group to see end result + ← Select alert group or "Use custom payload"
)} diff --git a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.tsx b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.tsx index 06f98e40..e9713449 100644 --- a/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.tsx +++ b/grafana-plugin/src/containers/OutgoingWebhook2Form/OutgoingWebhook2Form.config.tsx @@ -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, }, diff --git a/grafana-plugin/src/pages/integration_2/Integration2.tsx b/grafana-plugin/src/pages/integration_2/Integration2.tsx index 57d5dbe4..c7d3412e 100644 --- a/grafana-plugin/src/pages/integration_2/Integration2.tsx +++ b/grafana-plugin/src/pages/integration_2/Integration2.tsx @@ -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 isEditRegexpRouteTemplateModalOpen: false, channelFilterIdForEdit: undefined, isTemplateSettingsOpen: false, - newRoutes: [], isAddingRoute: false, + openRoutes: [], }; } @@ -116,9 +118,15 @@ class Integration2 extends React.Component } = 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 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 }, } = 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 ({ 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) => (