Merge pull request #4035 from grafana/dev

v1.3.111
This commit is contained in:
Matias Bordese 2024-03-07 17:47:25 -03:00 committed by GitHub
commit ce94eda820
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 683 additions and 259 deletions

2
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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:

View file

@ -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')},
},
),
]

View file

@ -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),
),
]

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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
)

View file

@ -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)

View file

@ -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):

View file

@ -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"},

View file

@ -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,

View file

@ -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,

View file

@ -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' });

View file

@ -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' });

View 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));
});

View file

@ -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');

View file

@ -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');

View file

@ -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' });
};

View file

@ -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}

View file

@ -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',
}

View file

@ -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;