From c85a9601a26a7abe060ce00c02f5fec9266d0ea5 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Thu, 7 Mar 2024 12:09:56 -0500 Subject: [PATCH 1/7] allow `@grafana/grafana-oncall` GitHub team users to approve changes to `/docs` (#4026) # What this PR does On https://github.com/grafana/oncall/pull/3992 I needed to poke the `docs-gops` team for an approval, even though it was only a one line change and can be safely approved by anyone on the OnCall team ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c98f91e1..be603202 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,6 @@ * @grafana/grafana-oncall-backend /grafana-plugin @grafana/grafana-oncall-frontend -/docs @grafana/docs-gops +/docs @grafana/docs-gops @grafana/grafana-oncall # `make docs` procedure is owned by @jdbaldry of @grafana/docs-squad. /.github/workflows/update-make-docs.yml @jdbaldry From f6b6bb053cbb6f31000340a9a831dee007aea790 Mon Sep 17 00:00:00 2001 From: Joey Orlando Date: Thu, 7 Mar 2024 12:28:58 -0500 Subject: [PATCH 2/7] remove `GOVERNANCE.md` and `MAINTAINERS.md` (#4021) # What this PR does See internal conversation [here](https://raintank-corp.slack.com/archives/C04JCU51NF8/p1709734053681209?thread_ts=1709116950.477509&cid=C04JCU51NF8). tldr; - `MAINTAINERS.md` - outdated + `.github/CODEOWNERS` contains the same/automated up-to-date information (via references to GitHub teams rather than individual users in a `.md` file) - `GOVERNANCE.md` - outdated; for example, a lot of references to GitHub discussions, which we do not use in this repo + another list of outdated users ## Which issue(s) this PR closes N/A ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- .github/pull_request_template.md | 2 + GOVERNANCE.md | 211 ------------------------------- MAINTAINERS.md | 20 --- 3 files changed, 2 insertions(+), 231 deletions(-) delete mode 100644 GOVERNANCE.md delete mode 100644 MAINTAINERS.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9929cec8..4424f012 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,9 +4,11 @@ Closes [issue link here] + ## Checklist diff --git a/GOVERNANCE.md b/GOVERNANCE.md deleted file mode 100644 index 78186fd3..00000000 --- a/GOVERNANCE.md +++ /dev/null @@ -1,211 +0,0 @@ -# Governance - -This document describes the rules and governance of the project. It is meant to be followed by all the developers -of the project and the OnCall community. Common terminology used in this governance document are listed below: - -- **Team members**: Any members of the private [team mailing list][team]. -- **Maintainers**: Maintainers lead an individual project or parts thereof ([`MAINTAINERS.md`][maintainers]). -- **Projects**: A single repository in the Grafana GitHub organization and listed below is referred to as a project: - - - oncall - -- **The OnCall project**: The sum of all activities performed under this governance, concerning one or more - repositories or the community. - -## Values - -The OnCall developers and community are expected to follow the values defined in the [Code of Conduct][coc]. Furthermore, -the OnCall community strives for kindness, giving feedback effectively, and building a welcoming environment. The OnCall -developers generally decide by consensus and only resort to conflict resolution by a majority vote if -consensus cannot be reached. - -## Projects - -Each project must have a [`MAINTAINERS.md`][maintainers] file with at least one maintainer. Where a project has a release -process, access and documentation should be such that more than one person can perform a release. Releases should be -announced on the [announcements][announce] category at the GitHub Discussions. Any new projects should be first proposed -on the [team mailing list][team] following the voting procedures listed below. - -## Decision making - -### Team members - -Team member status may be given to those who have made ongoing contributions to the OnCall project for at least 3 months. -This is usually in the form of code improvements and/or notable work on documentation, but organizing events or -user support could also be taken into account. - -New members may be proposed by any existing member by email to the [team mailing list][team]. It is highly desirable -to reach consensus about acceptance of a new member. However, the proposal is ultimately voted on by a -formal [supermajority vote](#supermajority-vote). - -If the new member proposal is accepted, the proposed team member should be contacted privately via email to confirm -or deny their acceptance of team membership. This email will also be CC'd to the [team mailing list][team] for -record-keeping purposes. - -If they choose to accept, the [onboarding](#onboarding) procedure is followed. - -Team members may retire at any time by emailing [the team][team]. - -Team members can be removed by [supermajority vote](#supermajority-vote) on [the team mailing list][team]. -For this vote, the member in question is not eligible to vote and does not count towards the quorum. -Any removal vote can cover only one single person. - -Upon death of a member, they leave the team automatically. - -In case a member leaves, the [offboarding](#offboarding) procedure is applied. - -The current team members are: - -- Ildar Iskhakov — [@iskhakov](https://github.com/iskhakov) ([Grafana Labs](https://grafana.com/)) -- Innokentii Konstantinov — [@Konstantinov-Innokentii](https://github.com/Konstantinov-Innokentii) ([Grafana Labs](https://grafana.com/)) -- Matías Bordese — [@matiasb](https://github.com/matiasb) ([Grafana Labs](https://grafana.com/)) -- Matvey Kukuy — [@Matvey-Kuk](https://github.com/Matvey-Kuk) ([Grafana Labs](https://grafana.com/)) -- Michael Derynck — [@mderynck](https://github.com/mderynck) ([Grafana Labs](https://grafana.com/)) -- Vadim Stepanov — [@vadimkerr](https://github.com/vadimkerr) ([Grafana Labs](https://grafana.com/)) -- Yulia Shanyrova — [@Ukochka](https://github.com/Ukochka) ([Grafana Labs](https://grafana.com/)) -- Maxim Mordasov — [@maskin25](https://github.com/maskin25) ([Grafana Labs](https://grafana.com/)) -- Julia Artyukhina — [@Ferril](https://github.com/Ferril) ([Grafana Labs](https://grafana.com/)) -- Joey Orlando - [@joeyorlando](https://github.com/joeyorlando) ([Grafana Labs](https://grafana.com/)) - -Previous team members: - -- n/a - -### Maintainers - -Maintainers lead one or more project(s) or parts thereof and serve as a point of conflict resolution amongst the -contributors to this project. Ideally, maintainers are also team members, but exceptions are possible for suitable -maintainers that, for whatever reason, are not yet team members. - -Changes in maintainership have to be announced on the [announcemount][announce] category at the GitHub Discussions. -They are decided by [rough consensus](#consensus) and formalized by changing the [`MAINTAINERS.md`][maintainers] -file of the respective repository. - -Maintainers are granted commit rights to all projects covered by this governance. - -A maintainer or committer may resign by notifying the [team mailing list][team]. A maintainer with no project activity -for a year is considered to have resigned. Maintainers that wish to resign are encouraged to propose another team -member to take over the project. - -A project may have multiple maintainers, as long as the responsibilities are clearly agreed upon between them. This -includes coordinating who handles which issues and pull requests. - -### Technical decisions - -Technical decisions that only affect a single project are made informally by the maintainer of this project, and -[rough consensus](#consensus) is assumed. Technical decisions that span multiple parts of the project should be -discussed and made on the the [GitHub Discussions][discussions]. - -Decisions are usually made by [rough consensus](#consensus). If no consensus can be reached, the matter may be resolved -by [majority vote](#majority-vote). - -### Governance changes - -Changes to this document are made by Grafana Labs. - -### Other matters - -Any matter that needs a decision may be called to a vote by any member if they deem it necessary. For private or -personnel matters, discussion and voting takes place on the [team mailing list][team], otherwise -on the [GitHub Discussions][discussions]. - -## Voting - -The OnCall project usually runs by informal consensus, however sometimes a formal decision must be made. - -Depending on the subject matter, as laid out [above](#decision-making), different methods of voting are used. - -For all votes, voting must be open for at least one week. The end date should be clearly stated in the call to vote. -A vote may be called and closed early if enough votes have come in one way so that further votes cannot -change the final decision. - -In all cases, all and only [team members](#team-members) are eligible to vote, with the sole exception of the forced -removal of a team member, in which said member is not eligible to vote. - -Discussion and votes on personnel matters (including but not limited to team membership and maintainership) are held in -private on the [team mailing list][team]. All other discussion and votes are held in public -on the [GitHub Discussions][discussions]. - -For public discussions, anyone interested is encouraged to participate. Formal power to object or vote is limited to -[team members](#team-members). - -### Consensus - -The default decision making mechanism for the OnCall project is [rough][rough] consensus. This means that any decision -on technical issues is considered supported by the [team][team] as long as nobody objects or the objection has been -considered but not necessarily accommodated. - -Silence on any consensus decision is implicit agreement and equivalent to explicit agreement. Explicit agreement may -be stated at will. Decisions may, but do not need to be called out and put up for decision on the -[GitHub Discussions][discussions] at any time and by anyone. - -Consensus decisions can never override or go against the spirit of an earlier explicit vote. - -If any [team member](#team-members) raises objections, the team members work together towards a solution that all -involved can accept. This solution is again subject to rough consensus. - -In case no consensus can be found, but a decision one way or the other must be made, any [team member](#team-members) -may call a formal [majority vote](#majority-vote). - -### Majority vote - -Majority votes must be called explicitly in a separate thread on the appropriate mailing list. -The subject must be prefixed with `[VOTE]`. In the body, the call to vote must state the proposal being voted on. -It should reference any discussion leading up to this point. - -Votes may take the form of a single proposal, with the option to vote yes or no, or the form of multiple alternatives. - -A vote on a single proposal is considered successful if more vote in favor than against. - -If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to all -alternatives. It is not possible to cast an “abstain” vote. A vote on multiple alternatives is considered decided in -favor of one alternative if it has received the most votes in favor, and a vote from more than half of those voting. -Should no alternative reach this quorum, another vote on a reduced number of options may be called separately. - -### Supermajority vote - -Supermajority votes must be called explicitly in a separate thread on the appropriate mailing list. -The subject must be prefixed with `[VOTE]`. In the body, the call to vote must state the proposal being voted on. -It should reference any discussion leading up to this point. - -Votes may take the form of a single proposal, with the option to vote yes or no, or the form of multiple alternatives. - -A vote on a single proposal is considered successful if at least two thirds of those eligible to vote vote in favor. - -If there are multiple alternatives, members may vote for one or more alternatives, or vote “no” to object to -all alternatives. A vote on multiple alternatives is considered decided in favor of one alternative if it has received -the most votes in favor, and a vote from at least two thirds of those eligible to vote. Should no alternative reach -this quorum, another vote on a reduced number of options may be called separately. - -## On- / Offboarding - -### Onboarding - -The new member is - -- added to the list of [team members](#team-members). Ideally by sending a PR of their own, at least approving said PR. -- announced on the [GitHub Discussions][discussions] by an existing team member. Ideally, the new member - replies in this thread, acknowledging team membership. -- added to the projects with commit rights. -- added to the [team mailing list][team]. - -### Offboarding - -The ex-member is - -- removed from the list of [team members](#team-members). Ideally by sending a PR of their own, at least approving said PR. - In case of forced removal, no approval is needed. -- removed from the projects. Optionally, they can retain maintainership of one or more repositories - if the [team](#team-members) agrees. -- removed from the team mailing list and demoted to a normal member of the other mailing lists. -- not allowed to call themselves an active team member any more, nor allowed to imply this to be the case. -- added to a list of previous members if they so choose. - -If needed, we reserve the right to publicly announce removal. - -[announce]: https://github.com/grafana/oncall/discussions/categories/announcements -[coc]: https://github.com/grafana/oncall/blob/dev/CODE_OF_CONDUCT.md -[maintainers]: https://github.com/grafana/oncall/blob/dev/MAINTAINERS.md -[rough]: https://tools.ietf.org/html/rfc7282 -[discussions]: https://github.com/grafana/oncall/discussions/ -[team]: TBD diff --git a/MAINTAINERS.md b/MAINTAINERS.md deleted file mode 100644 index adc819b4..00000000 --- a/MAINTAINERS.md +++ /dev/null @@ -1,20 +0,0 @@ -# Maintainers - -The following are the main/default maintainers: - -- Ildar Iskhakov — [@iskhakov](https://github.com/iskhakov) ([Grafana Labs](https://grafana.com/)) -- Matvey Kukuy — [@Matvey-Kuk](https://github.com/Matvey-Kuk) ([Grafana Labs](https://grafana.com/)) - -Some parts of the codebase have other maintainers, the package paths also include all sub-packages: - -Some parts of the codebase have other maintainers: - -- `docs`: - - Eve Meelan - [@Eve832](https://github.com/Eve832) ([Grafana Labs](https://grafana.com/)) - - Alyssa Wada - [@alyssawada](https://github.com/alyssawada) ([Grafana Labs](https://grafana.com/)) - -For the sake of brevity, not all subtrees are explicitly listed. Due to the -size of this repository, the natural changes in focus of maintainers over time, -and nuances of where particular features live, this list will always be -incomplete and out of date. However the listed maintainer(s) should be able to -direct a PR/question to the right person. From cf1fac8997592d54b6054d584269615637606a6a Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Thu, 7 Mar 2024 17:47:33 +0000 Subject: [PATCH 3/7] Backend support for "connected" integrations (#4030) # What this PR does Adds a Django model and internal API for connected integrations. Based on https://github.com/grafana/oncall/pull/3983 ## Which issue(s) this PR closes Related to https://github.com/grafana/oncall-private/issues/2540 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- .../0046_alertreceivechannelconnection.py | 27 +++ engine/apps/alerts/models/__init__.py | 1 + .../alert_receive_channel_connection.py | 20 ++ engine/apps/alerts/tests/factories.py | 6 + .../alert_receive_channel_connection.py | 36 ++++ .../api/tests/test_alert_receive_channel.py | 182 ++++++++++++++++++ .../apps/api/views/alert_receive_channel.py | 82 +++++++- engine/conftest.py | 15 ++ 8 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 engine/apps/alerts/migrations/0046_alertreceivechannelconnection.py create mode 100644 engine/apps/alerts/models/alert_receive_channel_connection.py create mode 100644 engine/apps/api/serializers/alert_receive_channel_connection.py diff --git a/engine/apps/alerts/migrations/0046_alertreceivechannelconnection.py b/engine/apps/alerts/migrations/0046_alertreceivechannelconnection.py new file mode 100644 index 00000000..1e7db964 --- /dev/null +++ b/engine/apps/alerts/migrations/0046_alertreceivechannelconnection.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.10 on 2024-03-07 13:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0045_escalationpolicy_notify_to_team_members_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='AlertReceiveChannelConnection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('backsync', models.BooleanField(default=False)), + ('connected_alert_receive_channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='source_alert_receive_channels', to='alerts.alertreceivechannel')), + ('source_alert_receive_channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='connected_alert_receive_channels', to='alerts.alertreceivechannel')), + ], + options={ + 'ordering': ['source_alert_receive_channel', 'connected_alert_receive_channel'], + 'unique_together': {('source_alert_receive_channel', 'connected_alert_receive_channel')}, + }, + ), + ] diff --git a/engine/apps/alerts/models/__init__.py b/engine/apps/alerts/models/__init__.py index 8b22ee86..5ca0bb36 100644 --- a/engine/apps/alerts/models/__init__.py +++ b/engine/apps/alerts/models/__init__.py @@ -4,6 +4,7 @@ from .alert_group_counter import AlertGroupCounter # noqa: F401 from .alert_group_log_record import AlertGroupLogRecord, listen_for_alertgrouplogrecord # noqa: F401 from .alert_manager_models import AlertForAlertManager, AlertGroupForAlertManager # noqa: F401 from .alert_receive_channel import AlertReceiveChannel, listen_for_alertreceivechannel_model_save # noqa: F401 +from .alert_receive_channel_connection import AlertReceiveChannelConnection # noqa: F401 from .channel_filter import ChannelFilter # noqa: F401 from .custom_button import CustomButton # noqa: F401 from .escalation_chain import EscalationChain # noqa: F401 diff --git a/engine/apps/alerts/models/alert_receive_channel_connection.py b/engine/apps/alerts/models/alert_receive_channel_connection.py new file mode 100644 index 00000000..19d9f73e --- /dev/null +++ b/engine/apps/alerts/models/alert_receive_channel_connection.py @@ -0,0 +1,20 @@ +from django.db import models + + +class AlertReceiveChannelConnection(models.Model): + """ + This model represents a connection between two integrations (e.g. when an Alertmanager integration is connected to a + ServiceNow integration). + """ + + source_alert_receive_channel = models.ForeignKey( + "AlertReceiveChannel", on_delete=models.CASCADE, related_name="connected_alert_receive_channels" + ) + connected_alert_receive_channel = models.ForeignKey( + "AlertReceiveChannel", on_delete=models.CASCADE, related_name="source_alert_receive_channels" + ) + backsync = models.BooleanField(default=False) + + class Meta: + ordering = ["source_alert_receive_channel", "connected_alert_receive_channel"] + unique_together = ("source_alert_receive_channel", "connected_alert_receive_channel") diff --git a/engine/apps/alerts/tests/factories.py b/engine/apps/alerts/tests/factories.py index 3c92b866..2b72c805 100644 --- a/engine/apps/alerts/tests/factories.py +++ b/engine/apps/alerts/tests/factories.py @@ -5,6 +5,7 @@ from apps.alerts.models import ( AlertGroup, AlertGroupLogRecord, AlertReceiveChannel, + AlertReceiveChannelConnection, ChannelFilter, CustomButton, EscalationChain, @@ -24,6 +25,11 @@ class AlertReceiveChannelFactory(factory.DjangoModelFactory): model = AlertReceiveChannel +class AlertReceiveChannelConnectionFactory(factory.DjangoModelFactory): + class Meta: + model = AlertReceiveChannelConnection + + class ChannelFilterFactory(factory.DjangoModelFactory): class Meta: model = ChannelFilter diff --git a/engine/apps/api/serializers/alert_receive_channel_connection.py b/engine/apps/api/serializers/alert_receive_channel_connection.py new file mode 100644 index 00000000..d196b7aa --- /dev/null +++ b/engine/apps/api/serializers/alert_receive_channel_connection.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + +from apps.alerts.models import AlertReceiveChannel, AlertReceiveChannelConnection +from apps.api.serializers.alert_receive_channel import FastAlertReceiveChannelSerializer + + +class AlertReceiveChannelSourceChannelSerializer(serializers.ModelSerializer): + alert_receive_channel = FastAlertReceiveChannelSerializer(source="source_alert_receive_channel", read_only=True) + backsync = serializers.BooleanField() + + class Meta: + model = AlertReceiveChannelConnection + fields = ["alert_receive_channel", "backsync"] + + +class AlertReceiveChannelConnectedChannelSerializer(serializers.ModelSerializer): + alert_receive_channel = FastAlertReceiveChannelSerializer(source="connected_alert_receive_channel", read_only=True) + backsync = serializers.BooleanField() + + class Meta: + model = AlertReceiveChannelConnection + fields = ["alert_receive_channel", "backsync"] + + +class AlertReceiveChannelConnectionSerializer(serializers.ModelSerializer): + source_alert_receive_channels = AlertReceiveChannelSourceChannelSerializer(read_only=True, many=True) + connected_alert_receive_channels = AlertReceiveChannelConnectedChannelSerializer(read_only=True, many=True) + + class Meta: + model = AlertReceiveChannel + fields = ["source_alert_receive_channels", "connected_alert_receive_channels"] + + +class AlertReceiveChannelNewConnectionSerializer(serializers.Serializer): + id = serializers.CharField() + backsync = serializers.BooleanField() diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 06d4405f..3ad4a8e9 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -1850,3 +1850,185 @@ def test_alert_receive_channel_webhooks_delete( webhook.refresh_from_db() assert webhook.deleted_at is not None assert alert_receive_channel.webhooks.count() == 0 + + +@pytest.mark.django_db +def test_connected_alert_receive_channels_get( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_alert_receive_channel_connection, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + source_alert_receive_channel = make_alert_receive_channel(organization) + connected_alert_receive_channel = make_alert_receive_channel(organization) + make_alert_receive_channel_connection(source_alert_receive_channel, connected_alert_receive_channel) + + # get integrations connected to source integration + client = APIClient() + url = reverse( + "api-internal:alert_receive_channel-connected-alert-receive-channels-get", + kwargs={"pk": source_alert_receive_channel.public_primary_key}, + ) + response = client.get(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "source_alert_receive_channels": [], + "connected_alert_receive_channels": [ + { + "alert_receive_channel": { + "id": connected_alert_receive_channel.public_primary_key, + "integration": connected_alert_receive_channel.integration, + "verbal_name": connected_alert_receive_channel.verbal_name, + "deleted": False, + }, + "backsync": False, + }, + ], + } + + # get source integrations for particular integration + url = reverse( + "api-internal:alert_receive_channel-connected-alert-receive-channels-get", + kwargs={"pk": connected_alert_receive_channel.public_primary_key}, + ) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "source_alert_receive_channels": [ + { + "alert_receive_channel": { + "id": source_alert_receive_channel.public_primary_key, + "integration": source_alert_receive_channel.integration, + "verbal_name": source_alert_receive_channel.verbal_name, + "deleted": False, + }, + "backsync": False, + }, + ], + "connected_alert_receive_channels": [], + } + + +@pytest.mark.django_db +def test_connected_alert_receive_channels_post( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + source_alert_receive_channel = make_alert_receive_channel(organization) + alert_receive_channel_to_connect_1 = make_alert_receive_channel(organization) + alert_receive_channel_to_connect_2 = make_alert_receive_channel(organization) + + client = APIClient() + url = reverse( + "api-internal:alert_receive_channel-connected-alert-receive-channels-get", + kwargs={"pk": source_alert_receive_channel.public_primary_key}, + ) + response = client.post( + url, + data=[ + {"id": alert_receive_channel_to_connect_1.public_primary_key, "backsync": False}, + {"id": alert_receive_channel_to_connect_2.public_primary_key, "backsync": True}, + ], + format="json", + **make_user_auth_headers(user, token), + ) + + assert response.status_code == status.HTTP_201_CREATED + assert response.json() == { + "source_alert_receive_channels": [], + "connected_alert_receive_channels": [ + { + "alert_receive_channel": { + "id": alert_receive_channel_to_connect_1.public_primary_key, + "integration": alert_receive_channel_to_connect_1.integration, + "verbal_name": alert_receive_channel_to_connect_1.verbal_name, + "deleted": False, + }, + "backsync": False, + }, + { + "alert_receive_channel": { + "id": alert_receive_channel_to_connect_2.public_primary_key, + "integration": alert_receive_channel_to_connect_2.integration, + "verbal_name": alert_receive_channel_to_connect_2.verbal_name, + "deleted": False, + }, + "backsync": True, + }, + ], + } + assert source_alert_receive_channel.connected_alert_receive_channels.count() == 2 + + +@pytest.mark.django_db +def test_connected_alert_receive_channels_put( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_alert_receive_channel_connection, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + source_alert_receive_channel = make_alert_receive_channel(organization) + connected_alert_receive_channel = make_alert_receive_channel(organization) + connection = make_alert_receive_channel_connection(source_alert_receive_channel, connected_alert_receive_channel) + + # update backsync for connected integration + client = APIClient() + url = reverse( + "api-internal:alert_receive_channel-connected-alert-receive-channels-put", + kwargs={ + "pk": source_alert_receive_channel.public_primary_key, + "connected_alert_receive_channel_id": connected_alert_receive_channel.public_primary_key, + }, + ) + response = client.put(url, data={"backsync": True}, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "alert_receive_channel": { + "id": connected_alert_receive_channel.public_primary_key, + "integration": connected_alert_receive_channel.integration, + "verbal_name": connected_alert_receive_channel.verbal_name, + "deleted": False, + }, + "backsync": True, + } + + connection.refresh_from_db() + assert connection.backsync is True + + +@pytest.mark.django_db +def test_connected_alert_receive_channels_delete( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_alert_receive_channel_connection, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + + source_alert_receive_channel = make_alert_receive_channel(organization) + connected_alert_receive_channel_1 = make_alert_receive_channel(organization) + connected_alert_receive_channel_2 = make_alert_receive_channel(organization) + + make_alert_receive_channel_connection(source_alert_receive_channel, connected_alert_receive_channel_1) + make_alert_receive_channel_connection(source_alert_receive_channel, connected_alert_receive_channel_2) + + client = APIClient() + url = reverse( + "api-internal:alert_receive_channel-connected-alert-receive-channels-put", + kwargs={ + "pk": source_alert_receive_channel.public_primary_key, + "connected_alert_receive_channel_id": connected_alert_receive_channel_1.public_primary_key, + }, + ) + response = client.delete(url, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert source_alert_receive_channel.connected_alert_receive_channels.count() == 1 + assert ( + source_alert_receive_channel.connected_alert_receive_channels.first().connected_alert_receive_channel + == connected_alert_receive_channel_2 + ) diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index 268405f1..80b67025 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -15,7 +15,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from apps.alerts.grafana_alerting_sync_manager.grafana_alerting_sync import GrafanaAlertingSyncManager -from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel +from apps.alerts.models import Alert, AlertGroup, AlertReceiveChannel, AlertReceiveChannelConnection from apps.alerts.models.maintainable_object import MaintainableObject from apps.api.label_filtering import parse_label_query from apps.api.permissions import RBACPermission @@ -24,6 +24,11 @@ from apps.api.serializers.alert_receive_channel import ( AlertReceiveChannelUpdateSerializer, FilterAlertReceiveChannelSerializer, ) +from apps.api.serializers.alert_receive_channel_connection import ( + AlertReceiveChannelConnectedChannelSerializer, + AlertReceiveChannelConnectionSerializer, + AlertReceiveChannelNewConnectionSerializer, +) from apps.api.serializers.webhook import WebhookSerializer from apps.api.throttlers import DemoAlertThrottler from apps.api.views.labels import schedule_update_label_cache @@ -155,6 +160,10 @@ class AlertReceiveChannelView( "webhooks_post": [RBACPermission.Permissions.INTEGRATIONS_WRITE], "webhooks_put": [RBACPermission.Permissions.INTEGRATIONS_WRITE], "webhooks_delete": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "connected_alert_receive_channels_get": [RBACPermission.Permissions.INTEGRATIONS_READ], + "connected_alert_receive_channels_post": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "connected_alert_receive_channels_put": [RBACPermission.Permissions.INTEGRATIONS_WRITE], + "connected_alert_receive_channels_delete": [RBACPermission.Permissions.INTEGRATIONS_WRITE], } def perform_update(self, serializer): @@ -676,3 +685,74 @@ class AlertReceiveChannelView( raise NotFound webhook.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema(request=None, responses=AlertReceiveChannelConnectionSerializer) + @action(detail=True, methods=["get"], url_path="connected_alert_receive_channels") + def connected_alert_receive_channels_get(self, request, pk): + instance = self.get_object() + return Response(AlertReceiveChannelConnectionSerializer(instance).data, status=status.HTTP_200_OK) + + @extend_schema( + request=AlertReceiveChannelNewConnectionSerializer(many=True), responses=AlertReceiveChannelConnectionSerializer + ) + @connected_alert_receive_channels_get.mapping.post + def connected_alert_receive_channels_post(self, request, pk): + instance = self.get_object() + serializer = AlertReceiveChannelNewConnectionSerializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + backsync_map = {connection["id"]: connection["backsync"] for connection in serializer.validated_data} + + # bulk create connections + AlertReceiveChannelConnection.objects.bulk_create( + [ + AlertReceiveChannelConnection( + source_alert_receive_channel=instance, + connected_alert_receive_channel=alert_receive_channel, + backsync=backsync_map[alert_receive_channel.public_primary_key], + ) + for alert_receive_channel in instance.organization.alert_receive_channels.filter( + public_primary_key__in=backsync_map.keys() + ) + ], + ignore_conflicts=True, + batch_size=5000, + ) + + return Response(AlertReceiveChannelConnectionSerializer(instance).data, status=status.HTTP_201_CREATED) + + @extend_schema( + request=AlertReceiveChannelConnectedChannelSerializer, + responses=AlertReceiveChannelConnectedChannelSerializer, + ) + @action( + detail=True, + methods=["put"], + url_path=r"connected_alert_receive_channels/(?P\w+)", + ) + def connected_alert_receive_channels_put(self, request, pk, connected_alert_receive_channel_id): + instance = self.get_object() + try: + connection = instance.connected_alert_receive_channels.get( + connected_alert_receive_channel_id__public_primary_key=connected_alert_receive_channel_id + ) + except ObjectDoesNotExist: + raise NotFound + + serializer = AlertReceiveChannelConnectedChannelSerializer(connection, data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema(request=None, responses=None) + @connected_alert_receive_channels_put.mapping.delete + def connected_alert_receive_channels_delete(self, request, pk, connected_alert_receive_channel_id): + instance = self.get_object() + try: + connection = instance.connected_alert_receive_channels.get( + connected_alert_receive_channel_id__public_primary_key=connected_alert_receive_channel_id + ) + except ObjectDoesNotExist: + raise NotFound + + connection.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/engine/conftest.py b/engine/conftest.py index 92f1a7c9..64fb7bf2 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -28,6 +28,7 @@ from apps.alerts.tests.factories import ( AlertFactory, AlertGroupFactory, AlertGroupLogRecordFactory, + AlertReceiveChannelConnectionFactory, AlertReceiveChannelFactory, ChannelFilterFactory, CustomActionFactory, @@ -103,6 +104,7 @@ register(TeamFactory) register(AlertReceiveChannelFactory) +register(AlertReceiveChannelConnectionFactory) register(ChannelFilterFactory) register(EscalationPolicyFactory) register(OnCallScheduleICalFactory) @@ -481,6 +483,19 @@ def make_alert_receive_channel(): return _make_alert_receive_channel +@pytest.fixture +def make_alert_receive_channel_connection(): + def _make_alert_receive_channel_connection(source_alert_receive_channel, connected_alert_receive_channel, **kwargs): + alert_receive_channel_connection = AlertReceiveChannelConnectionFactory( + source_alert_receive_channel=source_alert_receive_channel, + connected_alert_receive_channel=connected_alert_receive_channel, + **kwargs, + ) + return alert_receive_channel_connection + + return _make_alert_receive_channel_connection + + @pytest.fixture def make_alert_receive_channel_with_post_save_signal(): def _make_alert_receive_channel(organization, **kwargs): From 6d9d58d0dbcf3dd8ecc73056a2092be283c30435 Mon Sep 17 00:00:00 2001 From: Vadim Stepanov Date: Thu, 7 Mar 2024 17:53:44 +0000 Subject: [PATCH 4/7] Add `id_ne` filter for integrations (internal API) (#4032) # What this PR does Adds a new multiple choice `id_ne` (ID not equal) filter to internal API integrations endpoint. ## Which issue(s) this PR closes Related to https://github.com/grafana/oncall-private/issues/2540 ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- .../api/tests/test_alert_receive_channel.py | 31 +++++++++++++++++++ .../apps/api/views/alert_receive_channel.py | 6 ++++ 2 files changed, 37 insertions(+) diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 3ad4a8e9..2b3dba1d 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -57,6 +57,37 @@ def test_get_alert_receive_channel_by_integration_ne( assert result["integration"] != AlertReceiveChannel.INTEGRATION_DIRECT_PAGING +@pytest.mark.django_db +def test_get_alert_receive_channel_by_id_ne( + make_organization_and_user_with_plugin_token, make_organization, make_user_auth_headers, make_alert_receive_channel +): + organization, user, token = make_organization_and_user_with_plugin_token() + + alert_receive_channel_1 = make_alert_receive_channel(organization) + alert_receive_channel_2 = make_alert_receive_channel(organization) + alert_receive_channel_3 = make_alert_receive_channel(organization) + + # integration in a different org + organization = make_organization() + alert_receive_channel_4 = make_alert_receive_channel(organization) + + client = APIClient() + url = ( + reverse("api-internal:alert_receive_channel-list") + + f"?id_ne={alert_receive_channel_1.public_primary_key}&id_ne={alert_receive_channel_2.public_primary_key}" + ) + response = client.get(url, **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["id"] == alert_receive_channel_3.public_primary_key + + # integration in a different org shouldn't work + url = reverse("api-internal:alert_receive_channel-list") + f"?id_ne={alert_receive_channel_4.public_primary_key}" + response = client.get(url, **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db @pytest.mark.parametrize( "query_param,should_be_unpaginated", diff --git a/engine/apps/api/views/alert_receive_channel.py b/engine/apps/api/views/alert_receive_channel.py index 80b67025..50e48b29 100644 --- a/engine/apps/api/views/alert_receive_channel.py +++ b/engine/apps/api/views/alert_receive_channel.py @@ -68,6 +68,12 @@ class AlertReceiveChannelFilter(ByTeamModelFieldFilterMixin, filters.FilterSet): choices=AlertReceiveChannel.INTEGRATION_CHOICES, field_name="integration", exclude=True ) team = TeamModelMultipleChoiceFilter() + id_ne = filters.ModelMultipleChoiceFilter( + queryset=lambda request: request.auth.organization.alert_receive_channels.all(), + field_name="public_primary_key", + to_field_name="public_primary_key", + exclude=True, + ) class Meta: model = AlertReceiveChannel From 73dd14d695a294514d3878174ee17f7e610988e7 Mon Sep 17 00:00:00 2001 From: Michael Derynck Date: Thu, 7 Mar 2024 11:26:25 -0700 Subject: [PATCH 5/7] Move fcm_relay task from webhook to critical queue (#4034) # What this PR does The fcm_relay task was in the wrong queue, moves it to critical. ## Which issue(s) this PR closes ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --- engine/settings/celery_task_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/settings/celery_task_routes.py b/engine/settings/celery_task_routes.py index 436dfe55..1145feaa 100644 --- a/engine/settings/celery_task_routes.py +++ b/engine/settings/celery_task_routes.py @@ -123,6 +123,7 @@ CELERY_TASK_ROUTES = { "apps.mobile_app.tasks.going_oncall_notification.conditionally_send_going_oncall_push_notifications_for_all_schedules": { "queue": "critical" }, + "apps.mobile_app.fcm_relay.fcm_relay_async": {"queue": "critical"}, "apps.schedules.tasks.drop_cached_ical.drop_cached_ical_for_custom_events_for_organization": {"queue": "critical"}, "apps.schedules.tasks.drop_cached_ical.drop_cached_ical_task": {"queue": "critical"}, # GRAFANA @@ -178,7 +179,6 @@ CELERY_TASK_ROUTES = { # WEBHOOK "apps.alerts.tasks.custom_button_result.custom_button_result": {"queue": "webhook"}, "apps.alerts.tasks.custom_webhook_result.custom_webhook_result": {"queue": "webhook"}, - "apps.mobile_app.fcm_relay.fcm_relay_async": {"queue": "webhook"}, "apps.webhooks.tasks.trigger_webhook.execute_webhook": {"queue": "webhook"}, "apps.webhooks.tasks.trigger_webhook.send_webhook_event": {"queue": "webhook"}, "apps.webhooks.tasks.alert_group_status.alert_group_created": {"queue": "webhook"}, From d27bd6af516f07d19e55b5a7429bdf3f8dd7069a Mon Sep 17 00:00:00 2001 From: Matias Bordese Date: Thu, 7 Mar 2024 15:35:11 -0300 Subject: [PATCH 6/7] Add support for integration additional settings via custom serializer (#4027) Related to https://github.com/grafana/oncall-private/issues/2540 --- .../apps/alerts/integration_options_mixin.py | 13 +- ...alertreceivechannel_additional_settings.py | 18 +++ .../alerts/models/alert_receive_channel.py | 2 + .../api/serializers/alert_receive_channel.py | 45 ++++++ .../api/tests/test_alert_receive_channel.py | 152 +++++++++++++++++- 5 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 engine/apps/alerts/migrations/0047_alertreceivechannel_additional_settings.py diff --git a/engine/apps/alerts/integration_options_mixin.py b/engine/apps/alerts/integration_options_mixin.py index 2f4e0c24..f63afe30 100644 --- a/engine/apps/alerts/integration_options_mixin.py +++ b/engine/apps/alerts/integration_options_mixin.py @@ -17,9 +17,16 @@ class IntegrationOptionsMixin: super(IntegrationOptionsMixin, self).__init__(*args, **kwargs) # Object integration configs (imported as submodules earlier) are also available in `config` field, # e.g. instance.config.id, instance.config.slug, instance.config.description, etc... - for integration in self._config: - if integration.slug == self.integration: - self.config = integration + self.config = IntegrationOptionsMixin.get_config_from_type(self.integration) + + @classmethod + def get_config_from_type(cls, integration_type): + config = None + for integration in cls._config: + if integration.slug == integration_type: + config = integration + break + return config # Define variables for backward compatibility, e.g. INTEGRATION_GRAFANA, INTEGRATION_FORMATTED_WEBHOOK, etc... for integration_config in _config: diff --git a/engine/apps/alerts/migrations/0047_alertreceivechannel_additional_settings.py b/engine/apps/alerts/migrations/0047_alertreceivechannel_additional_settings.py new file mode 100644 index 00000000..10d20b54 --- /dev/null +++ b/engine/apps/alerts/migrations/0047_alertreceivechannel_additional_settings.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.10 on 2024-03-07 18:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0046_alertreceivechannelconnection'), + ] + + operations = [ + migrations.AddField( + model_name='alertreceivechannel', + name='additional_settings', + field=models.JSONField(default=None, null=True), + ), + ] diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index c35fbbb7..c56f441d 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -305,6 +305,8 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): alert_group_labels_template: str | None = models.TextField(null=True, default=None) """Stores a Jinja2 template for "advanced label templating" for alert group labels.""" + additional_settings: dict | None = models.JSONField(null=True, default=None) + class Meta: constraints = [ # This constraint ensures that there's at most one active direct paging integration per team diff --git a/engine/apps/api/serializers/alert_receive_channel.py b/engine/apps/api/serializers/alert_receive_channel.py index d7d01b45..a84896f9 100644 --- a/engine/apps/api/serializers/alert_receive_channel.py +++ b/engine/apps/api/serializers/alert_receive_channel.py @@ -26,6 +26,14 @@ from .integration_heartbeat import IntegrationHeartBeatSerializer from .labels import LabelsSerializerMixin +def _additional_settings_serializer_from_type(integration_type: str) -> serializers.Serializer: + """Return serializer class for given integration_type additional settings.""" + cls = None + config = AlertReceiveChannel.get_config_from_type(integration_type) + cls = getattr(config, "additional_settings_serializer", None) if config else None + return cls + + # AlertGroupCustomLabelValue represents custom alert group label value for API requests # It handles two types of label's value: # 1. Just Label Value from a label repo for a static label @@ -244,6 +252,7 @@ class AlertReceiveChannelSerializer( inbound_email = serializers.CharField(required=False, read_only=True) is_legacy = serializers.SerializerMethodField() alert_group_labels = IntegrationAlertGroupLabelsSerializer(source="*", required=False) + additional_settings = serializers.DictField(allow_null=True, allow_empty=False, required=False, default=None) # integration heartbeat is in PREFETCH_RELATED not by mistake. # With using of select_related ORM builds strange join @@ -286,6 +295,7 @@ class AlertReceiveChannelSerializer( "labels", "alert_group_labels", "alertmanager_v2_migrated_at", + "additional_settings", ] read_only_fields = [ "created_at", @@ -306,6 +316,28 @@ class AlertReceiveChannelSerializer( ] extra_kwargs = {"integration": {"required": True}} + def to_internal_value(self, data): + settings_serializer_cls = ( + _additional_settings_serializer_from_type(self.instance.config.slug) if self.instance else None + ) + if settings_serializer_cls: + additional_settings_data = data.get("additional_settings") + settings_serializer = settings_serializer_cls(self.instance, data=additional_settings_data) + settings_serializer.is_valid() + if settings_serializer.errors: + raise ValidationError({"additional_settings": settings_serializer.errors}) + data["additional_settings"] = settings_serializer.to_internal_value(additional_settings_data) + return super().to_internal_value(data) + + def to_representation(self, instance): + result = super().to_representation(instance) + if instance.additional_settings: + settings_serializer_cls = _additional_settings_serializer_from_type(instance.config.slug) + if settings_serializer_cls: + settings_serializer = settings_serializer_cls(instance) + result["additional_settings"] = settings_serializer.to_representation(instance) + return result + def validate(self, data): validated_data = super().validate(data) organization = self.context["request"].auth.organization @@ -396,6 +428,19 @@ class AlertReceiveChannelSerializer( return integration + def validate_additional_settings(self, data): + integration = self.instance.integration if self.instance else self.initial_data.get("integration") + settings_serializer_cls = _additional_settings_serializer_from_type(integration) + if settings_serializer_cls: + if not data: + raise ValidationError(["This field is required for this integration."]) + serializer = settings_serializer_cls(data=data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + elif data is not None: + raise ValidationError(["Invalid data"]) + return data + def get_allow_delete(self, obj: "AlertReceiveChannel") -> bool: # don't allow deleting direct paging integrations return obj.integration != AlertReceiveChannel.INTEGRATION_DIRECT_PAGING diff --git a/engine/apps/api/tests/test_alert_receive_channel.py b/engine/apps/api/tests/test_alert_receive_channel.py index 2b3dba1d..786190ba 100644 --- a/engine/apps/api/tests/test_alert_receive_channel.py +++ b/engine/apps/api/tests/test_alert_receive_channel.py @@ -3,7 +3,7 @@ from unittest.mock import ANY, patch import pytest from django.urls import reverse -from rest_framework import status +from rest_framework import serializers, status from rest_framework.response import Response from rest_framework.test import APIClient @@ -12,6 +12,23 @@ from apps.api.permissions import LegacyAccessControlRole from apps.labels.models import LabelKeyCache, LabelValueCache +class AdditionalSettingsTestSerializer(serializers.Serializer): + instance_url = serializers.CharField(required=True) + + def validate(self, data): + if hasattr(self, "initial_data"): + unknown_fields = set(self.initial_data.keys()) - set(self.fields.keys()) + if unknown_fields: + raise serializers.ValidationError("Unexpected fields: {}".format(unknown_fields)) + return data + + def to_internal_value(self, data): + return super().to_internal_value(data) + + def to_representation(self, instance): + return super().to_representation(instance.additional_settings) + + @pytest.fixture() def alert_receive_channel_internal_api_setup( make_organization_and_user_with_plugin_token, @@ -1729,6 +1746,139 @@ def test_team_not_updated_if_not_in_data( assert alert_receive_channel.team == team +@pytest.mark.django_db +def test_create_additional_settings_integration( + make_organization_and_user_with_plugin_token, + make_user_auth_headers, +): + _, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + + # set up additional settings for an integration + integration = AlertReceiveChannel._config[0] + integration.additional_settings_serializer = AdditionalSettingsTestSerializer + + url = reverse("api-internal:alert_receive_channel-list") + # create without additional_settings + data = { + "integration": integration.slug, + "team": None, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # create with empty additional_settings + data = {"integration": integration.slug, "team": None, "additional_settings": {}} + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "additional_settings" in response.json() + + # create with wrong additional_settings + data = { + "integration": integration.slug, + "team": None, + "additional_settings": {"test": "test"}, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # create with correct additional_settings + data = { + "integration": integration.slug, + "team": None, + "additional_settings": {"instance_url": "test"}, + } + response = client.post(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_201_CREATED + + +@pytest.mark.django_db +def test_update_additional_settings_integration( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + settings = {"instance_url": "test"} + + # set up additional settings for an integration + integration = AlertReceiveChannel._config[0] + integration.additional_settings_serializer = AdditionalSettingsTestSerializer + + alert_receive_channel = make_alert_receive_channel( + organization, integration=integration.slug, additional_settings=settings + ) + + url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key}) + # wrong additional_settings + data = {"additional_settings": {"test": "test", "username": "test", "password": "test"}} + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "additional_settings" in response.json() + + data = {"additional_settings": {}} + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "additional_settings" in response.json() + + data = {"additional_settings": None} + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "additional_settings" in response.json() + + data = { + "additional_settings": { + "test": "test", + "instance_url": "test2", + } + } + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "additional_settings" in response.json() + + # update with correct settings + data = { + "additional_settings": { + "instance_url": "test2", + } + } + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_200_OK + + alert_receive_channel.refresh_from_db() + assert alert_receive_channel.additional_settings == data["additional_settings"] + + +@pytest.mark.django_db +def test_update_other_integration_additional_settings( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + client = APIClient() + alert_receive_channel = make_alert_receive_channel(organization) + url = reverse("api-internal:alert_receive_channel-detail", kwargs={"pk": alert_receive_channel.public_primary_key}) + # integration doesn't have additional_settings + data = { + "additional_settings": { + "instance_url": "test", + "username": "test", + "password": "test", + "is_configured": True, + "state_mapping": { + "firing": [1, "New"], + "acknowledged": None, + "resolved": None, + "silenced": None, + }, + } + } + response = client.put(url, data, format="json", **make_user_auth_headers(user, token)) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def _webhook_data(webhook_id=ANY, webhook_name=ANY, webhook_url=ANY, alert_receive_channel_id=ANY): return { "authorization_header": None, From 6680abb60d82a80f875140cc9e37cc56b20870f0 Mon Sep 17 00:00:00 2001 From: Rares Mardare Date: Thu, 7 Mar 2024 22:28:51 +0200 Subject: [PATCH 7/7] Fixed schedules not showing tooltip for user details (#4033) # What this PR does ## Which issue(s) this PR closes ## Checklist - [x] Unit, integration, and e2e (if applicable) tests updated - [x] Documentation added (or `pr:no public docs` PR label added if not required) - [x] Added the relevant release notes label (see labels prefixed w/ `release:`). These labels dictate how your PR will show up in the autogenerated release notes. --------- Co-authored-by: Dominik --- .../e2e-tests/alerts/onCallSchedule.test.ts | 4 ++-- .../e2e-tests/insights/insights.test.ts | 4 ++-- .../e2e-tests/schedules/addOverride.test.ts | 4 ++-- .../e2e-tests/schedules/quality.test.ts | 4 ++-- .../schedules/scheduleDetails.test.ts | 18 ++++++++++++++++++ .../e2e-tests/schedules/schedulesList.test.ts | 4 ++-- .../e2e-tests/schedules/timezones.test.ts | 4 ++-- grafana-plugin/e2e-tests/utils/schedule.ts | 15 ++++++++++++--- .../ScheduleUserDetails.tsx | 2 +- .../src/models/loader/action-keys.ts | 1 - grafana-plugin/src/models/user/user.ts | 9 +++++---- 11 files changed, 48 insertions(+), 21 deletions(-) create mode 100644 grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts diff --git a/grafana-plugin/e2e-tests/alerts/onCallSchedule.test.ts b/grafana-plugin/e2e-tests/alerts/onCallSchedule.test.ts index 6859ec03..f2e8d0f1 100644 --- a/grafana-plugin/e2e-tests/alerts/onCallSchedule.test.ts +++ b/grafana-plugin/e2e-tests/alerts/onCallSchedule.test.ts @@ -3,7 +3,7 @@ import { verifyThatAlertGroupIsTriggered } from '../utils/alertGroup'; import { createEscalationChain, EscalationStep } from '../utils/escalationChain'; import { generateRandomValue } from '../utils/forms'; import { createIntegrationAndSendDemoAlert } from '../utils/integrations'; -import { createOnCallSchedule } from '../utils/schedule'; +import { createOnCallScheduleWithRotation } from '../utils/schedule'; test('we can create an oncall schedule + receive an alert', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; @@ -11,7 +11,7 @@ test('we can create an oncall schedule + receive an alert', async ({ adminRolePa const integrationName = generateRandomValue(); const onCallScheduleName = generateRandomValue(); - await createOnCallSchedule(page, onCallScheduleName, userName); + await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); await createEscalationChain( page, escalationChainName, diff --git a/grafana-plugin/e2e-tests/insights/insights.test.ts b/grafana-plugin/e2e-tests/insights/insights.test.ts index db2d9dd8..cf29ee22 100644 --- a/grafana-plugin/e2e-tests/insights/insights.test.ts +++ b/grafana-plugin/e2e-tests/insights/insights.test.ts @@ -6,7 +6,7 @@ import { createEscalationChain, EscalationStep } from '../utils/escalationChain' import { clickButton, generateRandomValue } from '../utils/forms'; import { createIntegrationAndSendDemoAlert } from '../utils/integrations'; import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation'; -import { createOnCallSchedule } from '../utils/schedule'; +import { createOnCallScheduleWithRotation } from '../utils/schedule'; /** * Insights is dependent on Scenes which were only added in Grafana 10.0.0 @@ -42,7 +42,7 @@ test.describe('Insights', () => { const escalationChainName = generateRandomValue(); const integrationName = generateRandomValue(); const onCallScheduleName = generateRandomValue(); - await createOnCallSchedule(page, onCallScheduleName, userName); + await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); await createEscalationChain( page, escalationChainName, diff --git a/grafana-plugin/e2e-tests/schedules/addOverride.test.ts b/grafana-plugin/e2e-tests/schedules/addOverride.test.ts index bcf56e9d..709c8551 100644 --- a/grafana-plugin/e2e-tests/schedules/addOverride.test.ts +++ b/grafana-plugin/e2e-tests/schedules/addOverride.test.ts @@ -2,13 +2,13 @@ import dayjs from 'dayjs'; import { test, expect } from '../fixtures'; import { clickButton, generateRandomValue } from '../utils/forms'; -import { createOnCallSchedule, getOverrideFormDateInputs } from '../utils/schedule'; +import { createOnCallScheduleWithRotation, getOverrideFormDateInputs } from '../utils/schedule'; test('default dates in override creation modal are correct', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; const onCallScheduleName = generateRandomValue(); - await createOnCallSchedule(page, onCallScheduleName, userName); + await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); await clickButton({ page, buttonText: 'Add override' }); diff --git a/grafana-plugin/e2e-tests/schedules/quality.test.ts b/grafana-plugin/e2e-tests/schedules/quality.test.ts index 82d84212..abd26d9c 100644 --- a/grafana-plugin/e2e-tests/schedules/quality.test.ts +++ b/grafana-plugin/e2e-tests/schedules/quality.test.ts @@ -1,12 +1,12 @@ import { test, expect } from '../fixtures'; import { generateRandomValue } from '../utils/forms'; -import { createOnCallSchedule } from '../utils/schedule'; +import { createOnCallScheduleWithRotation } from '../utils/schedule'; test('check schedule quality for simple 1-user schedule', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; const onCallScheduleName = generateRandomValue(); - await createOnCallSchedule(page, onCallScheduleName, userName); + await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); const scheduleQualityElement = page.getByTestId('schedule-quality'); await scheduleQualityElement.waitFor({ state: 'visible' }); diff --git a/grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts b/grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts new file mode 100644 index 00000000..5afabeea --- /dev/null +++ b/grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts @@ -0,0 +1,18 @@ +import { test, expect } from '../fixtures'; +import { generateRandomValue } from '../utils/forms'; +import { createOnCallScheduleWithRotation, createRotation } from '../utils/schedule'; + +test(`user can see the other user's details`, async ({ adminRolePage, editorRolePage }) => { + const { page, userName: adminUserName } = adminRolePage; + const editorUserName = editorRolePage.userName; + const onCallScheduleName = generateRandomValue(); + + await createOnCallScheduleWithRotation(page, onCallScheduleName, adminUserName); + await createRotation(page, editorUserName, false); + + await page.getByTestId('user-avatar-in-schedule').first().hover(); + await expect(page.getByTestId('schedule-user-details')).toHaveText(new RegExp(editorUserName)); + + await page.getByTestId('user-avatar-in-schedule').nth(1).hover(); + await expect(page.getByTestId('schedule-user-details')).toHaveText(new RegExp(adminUserName)); +}); diff --git a/grafana-plugin/e2e-tests/schedules/schedulesList.test.ts b/grafana-plugin/e2e-tests/schedules/schedulesList.test.ts index 31c50c1f..d83b570e 100644 --- a/grafana-plugin/e2e-tests/schedules/schedulesList.test.ts +++ b/grafana-plugin/e2e-tests/schedules/schedulesList.test.ts @@ -1,13 +1,13 @@ import { expect, test } from '../fixtures'; import { generateRandomValue } from '../utils/forms'; import { goToOnCallPage } from '../utils/navigation'; -import { createOnCallSchedule } from '../utils/schedule'; +import { createOnCallScheduleWithRotation } from '../utils/schedule'; test('schedule calendar and list of schedules is correctly displayed', async ({ adminRolePage }) => { const { page, userName } = adminRolePage; const onCallScheduleName = generateRandomValue(); - await createOnCallSchedule(page, onCallScheduleName, userName); + await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); await goToOnCallPage(page, 'schedules'); diff --git a/grafana-plugin/e2e-tests/schedules/timezones.test.ts b/grafana-plugin/e2e-tests/schedules/timezones.test.ts index d110b0c6..f9d098bd 100644 --- a/grafana-plugin/e2e-tests/schedules/timezones.test.ts +++ b/grafana-plugin/e2e-tests/schedules/timezones.test.ts @@ -6,7 +6,7 @@ import utc from 'dayjs/plugin/utc'; import { test } from '../fixtures'; import { clickButton, generateRandomValue } from '../utils/forms'; import { setTimezoneInProfile } from '../utils/grafanaProfile'; -import { createOnCallSchedule } from '../utils/schedule'; +import { createOnCallScheduleWithRotation } from '../utils/schedule'; dayjs.extend(utc); dayjs.extend(isoWeek); @@ -23,7 +23,7 @@ test('dates in schedule are correct according to selected current timezone', asy await setTimezoneInProfile(page, 'Europe/Moscow'); const onCallScheduleName = generateRandomValue(); - await createOnCallSchedule(page, onCallScheduleName, userName); + await createOnCallScheduleWithRotation(page, onCallScheduleName, userName); // Current timezone is selected by default to currently logged in user timezone await expect(page.getByTestId('timezone-select')).toHaveText('GMT+3'); diff --git a/grafana-plugin/e2e-tests/utils/schedule.ts b/grafana-plugin/e2e-tests/utils/schedule.ts index 3d76541a..e0e64f44 100644 --- a/grafana-plugin/e2e-tests/utils/schedule.ts +++ b/grafana-plugin/e2e-tests/utils/schedule.ts @@ -4,7 +4,11 @@ import dayjs from 'dayjs'; import { clickButton, selectDropdownValue } from './forms'; import { goToOnCallPage } from './navigation'; -export const createOnCallSchedule = async (page: Page, scheduleName: string, userName: string): Promise => { +export const createOnCallScheduleWithRotation = async ( + page: Page, + scheduleName: string, + userName: string +): Promise => { // go to the schedules page await goToOnCallPage(page, 'schedules'); @@ -18,15 +22,20 @@ export const createOnCallSchedule = async (page: Page, scheduleName: string, use // Add a new layer w/ the current user to it await clickButton({ page, buttonText: 'Create Schedule' }); - await clickButton({ page, buttonText: 'Add rotation' }); + await createRotation(page, userName); +}; +export const createRotation = async (page: Page, userName: string, isFirstScheduleRotation = true) => { + await clickButton({ page, buttonText: 'Add rotation' }); + if (!isFirstScheduleRotation) { + await page.getByText('Layer 1 rotation', { exact: true }).click(); + } await selectDropdownValue({ page, selectType: 'grafanaSelect', placeholderText: 'Add user', value: userName, }); - await clickButton({ page, buttonText: 'Create' }); }; diff --git a/grafana-plugin/src/containers/UsersTimezones/ScheduleUserDetails/ScheduleUserDetails.tsx b/grafana-plugin/src/containers/UsersTimezones/ScheduleUserDetails/ScheduleUserDetails.tsx index 2ac887db..bd22f168 100644 --- a/grafana-plugin/src/containers/UsersTimezones/ScheduleUserDetails/ScheduleUserDetails.tsx +++ b/grafana-plugin/src/containers/UsersTimezones/ScheduleUserDetails/ScheduleUserDetails.tsx @@ -48,7 +48,7 @@ export const ScheduleUserDetails: FC = observer((props organizationStore.currentOrganization.slack_team_identity?.cached_name?.replace(/[^0-9a-z]/gi, '') || ''; return ( -
+
{ @@ -93,6 +93,7 @@ export class UserStore { ...this.items, [data.pk]: { ...data, timezone: UserHelper.getTimezone(data) }, }; + delete this.usersCurrentlyBeingFetched[userPk]; }); return data;