commit
ce94eda820
28 changed files with 683 additions and 259 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
|
|
@ -4,9 +4,11 @@
|
|||
|
||||
Closes [issue link here]
|
||||
|
||||
<!--
|
||||
*Note*: if you have more than one GitHub issue that this PR closes, be sure to preface
|
||||
each issue link with a [closing keyword](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/using-keywords-in-issues-and-pull-requests#linking-a-pull-request-to-an-issue).
|
||||
This ensures that the issue(s) are auto-closed once the PR has been merged.
|
||||
-->
|
||||
|
||||
## Checklist
|
||||
|
||||
|
|
|
|||
211
GOVERNANCE.md
211
GOVERNANCE.md
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
|
|
@ -57,6 +74,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",
|
||||
|
|
@ -1698,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,
|
||||
|
|
@ -1850,3 +2031,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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -63,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
|
||||
|
|
@ -155,6 +166,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 +691,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<connected_alert_receive_channel_id>\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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
18
grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts
Normal file
18
grafana-plugin/e2e-tests/schedules/scheduleDetails.test.ts
Normal file
|
|
@ -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));
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
export const createOnCallScheduleWithRotation = async (
|
||||
page: Page,
|
||||
scheduleName: string,
|
||||
userName: string
|
||||
): Promise<void> => {
|
||||
// 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' });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = observer((props
|
|||
organizationStore.currentOrganization.slack_team_identity?.cached_name?.replace(/[^0-9a-z]/gi, '') || '';
|
||||
|
||||
return (
|
||||
<div className={cx('root')}>
|
||||
<div className={cx('root')} data-testid="schedule-user-details">
|
||||
<VerticalGroup spacing="xs">
|
||||
<ScheduleBorderedAvatar
|
||||
colors={colorSchemeList}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,5 @@ export enum ActionKey {
|
|||
FETCH_INCIDENTS_AND_STATS = 'FETCH_INCIDENTS_AND_STATS',
|
||||
UPDATE_FILTERS_AND_FETCH_INCIDENTS = 'UPDATE_FILTERS_AND_FETCH_INCIDENTS',
|
||||
FETCH_INTEGRATIONS = 'FETCH_INTEGRATIONS',
|
||||
FETCH_USERS = 'FETCH_USERS',
|
||||
TEST_CALL_OR_SMS = 'TEST_CALL_OR_SMS',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export class UserStore {
|
|||
notificationChoices: any = [];
|
||||
notifyByOptions: any = [];
|
||||
currentUserPk?: ApiSchemas['User']['pk'];
|
||||
usersCurrentlyBeingFetched: { [pk: string]: boolean } = {};
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
makeAutoObservable(this, undefined, { autoBind: true });
|
||||
|
|
@ -69,7 +70,6 @@ export class UserStore {
|
|||
return response;
|
||||
}
|
||||
|
||||
@AutoLoadingState(ActionKey.FETCH_USERS)
|
||||
@action.bound
|
||||
async fetchItemById({
|
||||
userPk,
|
||||
|
|
@ -80,12 +80,12 @@ export class UserStore {
|
|||
skipErrorHandling?: boolean;
|
||||
skipIfAlreadyPending?: boolean;
|
||||
}) {
|
||||
const isAlreadyFetching = this.rootStore.loaderStore.isLoading(ActionKey.FETCH_USERS);
|
||||
|
||||
if (skipIfAlreadyPending && isAlreadyFetching) {
|
||||
if (skipIfAlreadyPending && this.usersCurrentlyBeingFetched[userPk]) {
|
||||
return this.items[userPk];
|
||||
}
|
||||
|
||||
this.usersCurrentlyBeingFetched[userPk] = true;
|
||||
|
||||
const { data } = await onCallApi({ skipErrorHandling }).GET('/users/{id}/', { params: { path: { id: userPk } } });
|
||||
|
||||
runInAction(() => {
|
||||
|
|
@ -93,6 +93,7 @@ export class UserStore {
|
|||
...this.items,
|
||||
[data.pk]: { ...data, timezone: UserHelper.getTimezone(data) },
|
||||
};
|
||||
delete this.usersCurrentlyBeingFetched[userPk];
|
||||
});
|
||||
|
||||
return data;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue