Merge pull request #3076 from grafana/dev

Merge dev to main
This commit is contained in:
Michael Derynck 2023-09-27 13:06:40 -06:00 committed by GitHub
commit 65bca4a792
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 1887 additions and 458 deletions

View file

@ -5,7 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## v1.3.39 (2023-09-27)
### Added
- Presets for webhooks @mderynck ([#2996](https://github.com/grafana/oncall/pull/2996))
- Add `enable_web_overrides` option to schedules public API ([#3062](https://github.com/grafana/oncall/pull/3062))
### Fixed
- Fix regression in public actions endpoint handling user field by @mderynck ([#3053](https://github.com/grafana/oncall/pull/3053))
### Changed
- Rework how users are fetched from DB when getting users from schedules ical representation ([#3067](https://github.com/grafana/oncall/pull/3067))
## v1.3.38 (2023-09-19)
@ -20,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Notify user via Slack/mobile push-notification when their shift swap request is taken by @joeyorlando ([#2992](https://github.com/grafana/oncall/pull/2992))
- Unify breadcrumbs behaviour with other Grafana Apps and main core# ([1906](https://github.com/grafana/oncall/issues/1906))
### Changed

View file

@ -80,7 +80,7 @@ docs-pull: ## Pull documentation base image.
make-docs: ## Fetch the latest make-docs script.
make-docs:
if [[ ! -f "$(PWD)/make-docs" ]]; then
if [[ ! -f "$(CURDIR)/make-docs" ]]; then
echo 'WARN: No make-docs script found in the working directory. Run `make update` to download it.' >&2
exit 1
fi
@ -88,27 +88,27 @@ make-docs:
.PHONY: docs
docs: ## Serve documentation locally, which includes pulling the latest `DOCS_IMAGE` (default: `grafana/docs-base:latest`) container image. See also `docs-no-pull`.
docs: docs-pull make-docs
$(PWD)/make-docs $(PROJECTS)
$(CURDIR)/make-docs $(PROJECTS)
.PHONY: docs-no-pull
docs-no-pull: ## Serve documentation locally without pulling the `DOCS_IMAGE` (default: `grafana/docs-base:latest`) container image.
docs-no-pull: make-docs
$(PWD)/make-docs $(PROJECTS)
$(CURDIR)/make-docs $(PROJECTS)
.PHONY: docs-debug
docs-debug: ## Run Hugo web server with debugging enabled. TODO: support all SERVER_FLAGS defined in website Makefile.
docs-debug: make-docs
WEBSITE_EXEC='hugo server --bind 0.0.0.0 --port 3002 --debug' $(PWD)/make-docs $(PROJECTS)
WEBSITE_EXEC='hugo server --bind 0.0.0.0 --port 3002 --debug' $(CURDIR)/make-docs $(PROJECTS)
.PHONY: doc-validator
doc-validator: ## Run doc-validator on the entire docs folder.
doc-validator: make-docs
DOCS_IMAGE=$(DOC_VALIDATOR_IMAGE) $(PWD)/make-docs $(PROJECTS)
DOCS_IMAGE=$(DOC_VALIDATOR_IMAGE) $(CURDIR)/make-docs $(PROJECTS)
.PHONY: vale
vale: ## Run vale on the entire docs folder.
vale: make-docs
DOCS_IMAGE=$(VALE_IMAGE) $(PWD)/make-docs $(PROJECTS)
DOCS_IMAGE=$(VALE_IMAGE) $(CURDIR)/make-docs $(PROJECTS)
.PHONY: update
update: ## Fetch the latest version of this Makefile and the `make-docs` script from Writers' Toolkit.

View file

@ -47,6 +47,7 @@ The above command returns JSON structured in the following way:
| `time_zone` | No | Optional | Schedule time zone. Is used for manually added on-call shifts in Schedules with type `calendar`. Default time zone is `UTC`. For more information about time zones, see [time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). |
| `ical_url_primary` | No | If type = `ical` | URL of external iCal calendar for schedule with type `ical`. |
| `ical_url_overrides` | No | Optional | URL of external iCal calendar for schedule with any type. Events from this calendar override events from primary calendar or from on-call shifts. |
| `enable_web_overrides` | No | Optional | Whether to enable web overrides or not. Setting specific for API/Terraform based schedules (`calendar` type). |
| `slack` | No | Optional | Dictionary with Slack-specific settings for a schedule. Includes `channel_id` and `user_group_id` fields, that take a channel ID and a user group ID from Slack. |
| `shifts` | No | Optional | List of shifts. Used for manually added on-call shifts in Schedules with type `calendar`. |

View file

@ -30,8 +30,10 @@ Jinja2 templates to customize the request being sent.
## Creating an outgoing webhook
To create an outgoing webhook navigate to **Outgoing Webhooks** and click **+ Create**. On this screen outgoing
webhooks can be viewed, edited and deleted. To create the outgoing webhook populate the required fields and
click **Create Webhook**
webhooks can be viewed, edited and deleted. To create the outgoing webhook click **New Outgoing Webhook** and then
select a preset based on what you want to do. A simple webhook will POST alert group data as a selectable escalation
step to the specified url. If you require more customization use the advanced webhook which provides all of the
fields described below.
### Outgoing webhook fields

View file

@ -4,7 +4,8 @@ from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from apps.webhooks.models import Webhook, WebhookResponse
from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER
from apps.webhooks.models.webhook import PUBLIC_WEBHOOK_HTTP_METHODS, WEBHOOK_FIELD_PLACEHOLDER
from apps.webhooks.presets.preset_options import WebhookPresetOptions
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefault, CurrentUserDefault
from common.jinja_templater import apply_jinja_template
@ -31,9 +32,9 @@ class WebhookSerializer(serializers.ModelSerializer):
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
team = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault())
user = serializers.HiddenField(default=CurrentUserDefault())
trigger_type = serializers.CharField(required=True)
forward_all = serializers.BooleanField(allow_null=True, required=False)
last_response_log = serializers.SerializerMethodField()
trigger_type = serializers.CharField(allow_null=True)
trigger_type_name = serializers.SerializerMethodField()
class Meta:
@ -59,11 +60,8 @@ class WebhookSerializer(serializers.ModelSerializer):
"trigger_type_name",
"last_response_log",
"integration_filter",
"preset",
]
extra_kwargs = {
"name": {"required": True, "allow_null": False, "allow_blank": False},
"url": {"required": True, "allow_null": False, "allow_blank": False},
}
validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])]
@ -78,6 +76,16 @@ class WebhookSerializer(serializers.ModelSerializer):
def to_internal_value(self, data):
webhook = self.instance
# Some fields are conditionally required, add none values for missing required fields
if webhook and webhook.preset and "preset" not in data:
data["preset"] = webhook.preset
for key in ["url", "http_method", "trigger_type"]:
if key not in data:
if self.instance:
data[key] = getattr(self.instance, key)
else:
data[key] = None
# If webhook is being copied instance won't exist to copy values from
if not webhook and "id" in data:
webhook = Webhook.objects.get(
@ -111,10 +119,29 @@ class WebhookSerializer(serializers.ModelSerializer):
return self._validate_template_field(headers)
def validate_url(self, url):
if self.is_field_controlled("url"):
return url
if not url:
return None
raise serializers.ValidationError(detail="This field is required.")
return self._validate_template_field(url)
def validate_http_method(self, http_method):
if self.is_field_controlled("http_method"):
return http_method
if http_method not in PUBLIC_WEBHOOK_HTTP_METHODS:
raise serializers.ValidationError(detail=f"This field must be one of {PUBLIC_WEBHOOK_HTTP_METHODS}.")
return http_method
def validate_trigger_type(self, trigger_type):
if self.is_field_controlled("trigger_type"):
return trigger_type
if not trigger_type or int(trigger_type) not in Webhook.ALL_TRIGGER_TYPES:
raise serializers.ValidationError(detail="This field is required.")
return trigger_type
def validate_data(self, data):
if not data:
return None
@ -125,6 +152,29 @@ class WebhookSerializer(serializers.ModelSerializer):
return False
return data
def validate_preset(self, preset):
if self.instance and self.instance.preset != preset:
raise serializers.ValidationError(detail="This field once set cannot be modified.")
if preset:
if preset not in WebhookPresetOptions.WEBHOOK_PRESETS:
raise serializers.ValidationError(detail=f"{preset} is not a valid preset id.")
preset_metadata = WebhookPresetOptions.WEBHOOK_PRESETS[preset].metadata
for controlled_field in preset_metadata.controlled_fields:
if controlled_field in self.initial_data:
if self.instance:
if self.initial_data[controlled_field] != getattr(self.instance, controlled_field):
raise serializers.ValidationError(
detail=f"{controlled_field} is controlled by preset, cannot update"
)
elif self.initial_data[controlled_field] is not None:
raise serializers.ValidationError(
detail=f"{controlled_field} is controlled by preset, cannot create"
)
return preset
def get_last_response_log(self, obj):
return WebhookResponseSerializer(obj.responses.all().last()).data
@ -133,3 +183,20 @@ class WebhookSerializer(serializers.ModelSerializer):
if obj.trigger_type is not None:
trigger_type_name = Webhook.TRIGGER_TYPES[int(obj.trigger_type)][1]
return trigger_type_name
def is_field_controlled(self, field_name):
if self.instance:
if not self.instance.preset:
return False
elif "preset" not in self.initial_data:
return False
preset_id = self.instance.preset if self.instance else self.initial_data["preset"]
if preset_id:
if preset_id not in WebhookPresetOptions.WEBHOOK_PRESETS:
raise serializers.ValidationError(detail=f"unknown preset {preset_id} referenced")
preset = WebhookPresetOptions.WEBHOOK_PRESETS[preset_id]
if field_name not in preset.metadata.controlled_fields:
return False
return True

View file

@ -2081,7 +2081,7 @@ def test_get_schedule_on_call_now(
client = APIClient()
url = reverse("api-internal:schedule-list")
with patch(
"apps.schedules.models.on_call_schedule.OnCallScheduleQuerySet.get_oncall_users",
"apps.api.views.schedule.get_oncall_users_for_multiple_schedules",
return_value={schedule.pk: [user]},
):
response = client.get(url, format="json", **make_user_auth_headers(user, token))

View file

@ -0,0 +1,161 @@
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.webhooks.models import Webhook
from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER
from apps.webhooks.tests.test_webhook_presets import (
TEST_WEBHOOK_LOGO,
TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS,
TEST_WEBHOOK_PRESET_DESCRIPTION,
TEST_WEBHOOK_PRESET_ID,
TEST_WEBHOOK_PRESET_NAME,
TEST_WEBHOOK_PRESET_URL,
)
@pytest.mark.django_db
def test_get_webhook_preset_options(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:webhooks-preset-options")
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.data[0]["id"] == TEST_WEBHOOK_PRESET_ID
assert response.data[0]["name"] == TEST_WEBHOOK_PRESET_NAME
assert response.data[0]["logo"] == TEST_WEBHOOK_LOGO
assert response.data[0]["description"] == TEST_WEBHOOK_PRESET_DESCRIPTION
assert response.data[0]["controlled_fields"] == TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS
@pytest.mark.django_db
def test_create_webhook_from_preset(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:webhooks-list")
data = {
"name": "the_webhook",
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"team": None,
"password": "secret_password",
"preset": TEST_WEBHOOK_PRESET_ID,
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
webhook = Webhook.objects.get(public_primary_key=response.data["id"])
expected_response = data | {
"id": webhook.public_primary_key,
"url": TEST_WEBHOOK_PRESET_URL,
"data": organization.org_title,
"username": None,
"password": WEBHOOK_FIELD_PLACEHOLDER,
"authorization_header": None,
"forward_all": True,
"headers": None,
"http_method": "GET",
"integration_filter": None,
"is_webhook_enabled": True,
"is_legacy": False,
"last_response_log": {
"request_data": "",
"request_headers": "",
"timestamp": None,
"content": "",
"status_code": None,
"request_trigger": "",
"url": "",
"event_data": "",
},
"trigger_template": None,
"trigger_type": str(data["trigger_type"]),
"trigger_type_name": "Alert Group Created",
}
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == expected_response
assert webhook.password == data["password"]
@pytest.mark.django_db
def test_invalid_create_webhook_with_preset(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers
):
organization, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:webhooks-list")
data = {
"name": "the_webhook",
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"url": "https://test12345.com",
"preset": TEST_WEBHOOK_PRESET_ID,
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["preset"][0] == "url is controlled by preset, cannot create"
@pytest.mark.django_db
def test_update_webhook_from_preset(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers, make_custom_webhook
):
organization, user, token = make_organization_and_user_with_plugin_token()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)
client = APIClient()
url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
data = {
"name": "the_webhook 2",
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == data["name"]
webhook.refresh_from_db()
assert webhook.name == data["name"]
assert webhook.url == TEST_WEBHOOK_PRESET_URL
assert webhook.http_method == "GET"
assert webhook.data == organization.org_title
@pytest.mark.django_db
def test_invalid_update_webhook_from_preset(
make_organization_and_user_with_plugin_token, webhook_preset_api_setup, make_user_auth_headers, make_custom_webhook
):
organization, user, token = make_organization_and_user_with_plugin_token()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)
client = APIClient()
url = reverse("api-internal:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
data = {
"preset": "some_other_preset",
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["preset"][0] == "This field once set cannot be modified."
data = {
"data": "some_other_data",
}
response = client.put(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST

View file

@ -66,6 +66,7 @@ def test_get_list_webhooks(webhook_internal_api_setup, make_user_auth_headers):
"trigger_template": None,
"trigger_type": "0",
"trigger_type_name": "Escalation step",
"preset": None,
}
]
@ -108,6 +109,7 @@ def test_get_detail_webhook(webhook_internal_api_setup, make_user_auth_headers):
"trigger_template": None,
"trigger_type": "0",
"trigger_type_name": "Escalation step",
"preset": None,
}
response = client.get(url, format="json", **make_user_auth_headers(user, token))
@ -124,7 +126,8 @@ def test_create_webhook(webhook_internal_api_setup, make_user_auth_headers):
data = {
"name": "the_webhook",
"url": TEST_URL,
"trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED),
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "POST",
"team": None,
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
@ -152,7 +155,9 @@ def test_create_webhook(webhook_internal_api_setup, make_user_auth_headers):
"event_data": "",
},
"trigger_template": None,
"trigger_type": str(data["trigger_type"]),
"trigger_type_name": "Alert Group Created",
"preset": None,
}
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == expected_response
@ -179,7 +184,8 @@ def test_create_valid_templated_field(webhook_internal_api_setup, make_user_auth
"name": "webhook_with_valid_data",
"url": TEST_URL,
field_name: value,
"trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED),
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "POST",
"team": None,
}
@ -209,7 +215,9 @@ def test_create_valid_templated_field(webhook_internal_api_setup, make_user_auth
"event_data": "",
},
"trigger_template": None,
"trigger_type": str(data["trigger_type"]),
"trigger_type_name": "Alert Group Created",
"preset": None,
}
# update expected value for changed field
expected_response[field_name] = value
@ -236,7 +244,8 @@ def test_create_invalid_templated_field(webhook_internal_api_setup, make_user_au
"name": "webhook_with_valid_data",
"url": TEST_URL,
field_name: value,
"trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED),
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "POST",
"team": None,
}
@ -253,7 +262,8 @@ def test_update_webhook(webhook_internal_api_setup, make_user_auth_headers):
data = {
"name": "github_button_updated",
"url": "https://github.com/",
"trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED),
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "POST",
"team": None,
}
response = client.put(
@ -547,7 +557,8 @@ def test_webhook_field_masking(webhook_internal_api_setup, make_user_auth_header
data = {
"name": "the_webhook",
"url": TEST_URL,
"trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED),
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "POST",
"team": None,
"password": "secret_password",
"authorization_header": "auth 1234",
@ -579,7 +590,9 @@ def test_webhook_field_masking(webhook_internal_api_setup, make_user_auth_header
"event_data": "",
},
"trigger_template": None,
"trigger_type": str(data["trigger_type"]),
"trigger_type_name": "Alert Group Created",
"preset": None,
}
assert response.status_code == status.HTTP_201_CREATED
@ -598,7 +611,8 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers):
data = {
"name": "the_webhook",
"url": TEST_URL,
"trigger_type": str(Webhook.TRIGGER_ALERT_GROUP_CREATED),
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "POST",
"team": None,
"password": "secret_password",
"authorization_header": "auth 1234",
@ -635,7 +649,9 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers):
"event_data": "",
},
"trigger_template": None,
"trigger_type": str(data["trigger_type"]),
"trigger_type_name": "Alert Group Created",
"preset": None,
}
assert response3.status_code == status.HTTP_201_CREATED
@ -644,3 +660,49 @@ def test_webhook_copy(webhook_internal_api_setup, make_user_auth_headers):
assert webhook.authorization_header == data["authorization_header"]
assert webhook.id != to_copy["id"]
assert webhook.user == user
@pytest.mark.django_db
def test_create_invalid_missing_fields(webhook_internal_api_setup, make_user_auth_headers):
user, token, webhook = webhook_internal_api_setup
client = APIClient()
url = reverse("api-internal:webhooks-list")
data = {"url": TEST_URL, "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, "http_method": "POST"}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["name"][0] == "This field is required."
data = {"name": "test webhook 1", "trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED, "http_method": "POST"}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["url"][0] == "This field is required."
data = {"name": "test webhook 2", "url": TEST_URL, "http_method": "POST"}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["trigger_type"][0] == "This field is required."
data = {
"name": "test webhook 3",
"url": TEST_URL,
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["http_method"][0] == "This field must be one of ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']."
data = {
"name": "test webhook 3",
"url": TEST_URL,
"trigger_type": Webhook.TRIGGER_ALERT_GROUP_CREATED,
"http_method": "TOAST",
}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["http_method"][0] == "This field must be one of ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']."
data = {"name": "test webhook 3", "url": TEST_URL, "trigger_type": 2000000, "http_method": "POST"}
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["trigger_type"][0] == "This field is required."

View file

@ -20,6 +20,7 @@ from apps.api.serializers.alert_receive_channel import (
from apps.api.throttlers import DemoAlertThrottler
from apps.auth_token.auth import PluginAuthentication
from apps.integrations.legacy_prefix import has_legacy_prefix, remove_legacy_prefix
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, TeamModelMultipleChoiceFilter
from common.api_helpers.mixins import (
@ -72,7 +73,10 @@ class AlertReceiveChannelView(
UpdateSerializerMixin,
ModelViewSet,
):
authentication_classes = (PluginAuthentication,)
authentication_classes = (
MobileAppAuthTokenAuthentication,
PluginAuthentication,
)
permission_classes = (IsAuthenticated, RBACPermission)
model = AlertReceiveChannel

View file

@ -15,6 +15,7 @@ from apps.api.serializers.escalation_chain import (
FilterEscalationChainSerializer,
)
from apps.auth_token.auth import PluginAuthentication
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.user_management.models import Team
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
@ -38,7 +39,10 @@ class EscalationChainViewSet(
ListSerializerMixin,
viewsets.ModelViewSet,
):
authentication_classes = (PluginAuthentication,)
authentication_classes = (
MobileAppAuthTokenAuthentication,
PluginAuthentication,
)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {

View file

@ -6,11 +6,15 @@ from apps.alerts.tasks import send_update_resolution_note_signal
from apps.api.permissions import RBACPermission
from apps.api.serializers.resolution_note import ResolutionNoteSerializer, ResolutionNoteUpdateSerializer
from apps.auth_token.auth import PluginAuthentication
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from common.api_helpers.mixins import PublicPrimaryKeyMixin, TeamFilteringMixin, UpdateSerializerMixin
class ResolutionNoteView(TeamFilteringMixin, PublicPrimaryKeyMixin, UpdateSerializerMixin, ModelViewSet):
authentication_classes = (PluginAuthentication,)
authentication_classes = (
MobileAppAuthTokenAuthentication,
PluginAuthentication,
)
permission_classes = (IsAuthenticated, RBACPermission)
rbac_permissions = {

View file

@ -34,6 +34,7 @@ from apps.auth_token.auth import PluginAuthentication
from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME
from apps.auth_token.models import ScheduleExportAuthToken
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.schedules.ical_utils import get_oncall_users_for_multiple_schedules
from apps.schedules.models import OnCallSchedule
from apps.slack.models import SlackChannel
from apps.slack.tasks import update_slack_user_group_for_schedules
@ -136,10 +137,8 @@ class ScheduleView(
The result of this method is cached and is reused for the whole lifetime of a request,
since self.get_serializer_context() is called multiple times for every instance in the queryset.
"""
current_page_schedules = self.paginate_queryset(self.filter_queryset(self.get_queryset()))
pks = [schedule.pk for schedule in current_page_schedules]
queryset = OnCallSchedule.objects.filter(pk__in=pks)
return queryset.get_oncall_users()
current_page_schedules = self.paginate_queryset(self.filter_queryset(self.get_queryset(annotate=False)))
return get_oncall_users_for_multiple_schedules(current_page_schedules)
def get_serializer_context(self):
context = super().get_serializer_context()
@ -167,7 +166,7 @@ class ScheduleView(
)
return queryset
def get_queryset(self, ignore_filtering_by_available_teams=False):
def get_queryset(self, ignore_filtering_by_available_teams=False, annotate=True):
is_short_request = self.request.query_params.get("short", "false") == "true"
filter_by_type = self.request.query_params.getlist("type")
mine = BooleanField(allow_null=True).to_internal_value(data=self.request.query_params.get("mine"))
@ -181,7 +180,7 @@ class ScheduleView(
)
if not ignore_filtering_by_available_teams:
queryset = queryset.filter(*self.available_teams_lookup_args).distinct()
if not is_short_request:
if not is_short_request or annotate:
queryset = self._annotate_queryset(queryset)
queryset = self.serializer_class.setup_eager_loading(queryset)
if filter_by_type:
@ -231,15 +230,16 @@ class ScheduleView(
if instance.user_group is not None:
update_slack_user_group_for_schedules.apply_async((instance.user_group.pk,))
def get_object(self) -> OnCallSchedule:
def get_object(self, annotate=True) -> OnCallSchedule:
# get the object from the whole organization if there is a flag `get_from_organization=true`
# otherwise get the object from the current team
get_from_organization: bool = self.request.query_params.get("from_organization", "false") == "true"
if get_from_organization:
return self.get_object_from_organization()
return super().get_object()
return self.get_object_from_organization(annotate=annotate)
queryset_kwargs = {"annotate": annotate}
return super().get_object(queryset_kwargs)
def get_object_from_organization(self, ignore_filtering_by_available_teams=False):
def get_object_from_organization(self, ignore_filtering_by_available_teams=False, annotate=True):
# use this method to get the object from the whole organization instead of the current team
pk = self.kwargs["pk"]
organization = self.request.auth.organization
@ -248,7 +248,10 @@ class ScheduleView(
)
if not ignore_filtering_by_available_teams:
queryset = queryset.filter(*self.available_teams_lookup_args).distinct()
queryset = self._annotate_queryset(queryset)
if annotate:
queryset = self._annotate_queryset(queryset)
queryset = self.serializer_class.setup_eager_loading(queryset)
try:
obj = queryset.get()
@ -283,7 +286,7 @@ class ScheduleView(
with_empty = self.request.query_params.get("with_empty", False) == "true"
with_gap = self.request.query_params.get("with_gap", False) == "true"
schedule = self.get_object()
schedule = self.get_object(annotate=False)
pytz_tz = pytz.timezone(user_tz)
datetime_start = datetime.datetime.combine(starting_date, datetime.time.min, tzinfo=pytz_tz)
@ -319,7 +322,7 @@ class ScheduleView(
raise BadRequest(detail="Invalid type value")
resolve_schedule = filter_by is None or filter_by == EVENTS_FILTER_BY_FINAL
schedule = self.get_object()
schedule = self.get_object(annotate=False)
pytz_tz = pytz.timezone(user_tz)
datetime_start = datetime.datetime.combine(starting_date, datetime.time.min, tzinfo=pytz_tz)
@ -349,7 +352,7 @@ class ScheduleView(
@action(detail=True, methods=["get"])
def filter_shift_swaps(self, request: Request, pk: str) -> Response:
user_tz, starting_date, days = get_date_range_from_request(self.request)
schedule = self.get_object()
schedule = self.get_object(annotate=False)
pytz_tz = pytz.timezone(user_tz)
datetime_start = datetime.datetime.combine(starting_date, datetime.time.min, tzinfo=pytz_tz)
@ -367,7 +370,7 @@ class ScheduleView(
"""Return next shift for users in schedule."""
now = timezone.now()
datetime_end = now + datetime.timedelta(days=30)
schedule = self.get_object()
schedule = self.get_object(annotate=False)
events = schedule.final_events(now, datetime_end)
@ -382,7 +385,7 @@ class ScheduleView(
@action(detail=True, methods=["get"])
def related_users(self, request, pk):
schedule = self.get_object()
schedule = self.get_object(annotate=False)
serializer = ScheduleUserSerializer(schedule.related_users(), many=True)
result = {"users": serializer.data}
return Response(result, status=status.HTTP_200_OK)
@ -390,7 +393,7 @@ class ScheduleView(
@action(detail=True, methods=["get"])
def related_escalation_chains(self, request, pk):
"""Return escalation chains associated to schedule."""
schedule = self.get_object()
schedule = self.get_object(annotate=True)
escalation_chains = EscalationChain.objects.filter(escalation_policies__notify_schedule=schedule).distinct()
result = [{"name": e.name, "pk": e.public_primary_key} for e in escalation_chains]
@ -398,7 +401,7 @@ class ScheduleView(
@action(detail=True, methods=["get"])
def quality(self, request, pk):
schedule = self.get_object()
schedule = self.get_object(annotate=False)
_, date = self.get_request_timezone()
datetime_start = datetime.datetime.combine(date, datetime.time.min, tzinfo=pytz.UTC)
@ -440,7 +443,7 @@ class ScheduleView(
@action(detail=True, methods=["post"])
def reload_ical(self, request, pk):
schedule = self.get_object()
schedule = self.get_object(annotate=False)
schedule.drop_cached_ical()
schedule.check_empty_shifts_for_next_week()
schedule.check_gaps_for_next_week()
@ -452,7 +455,7 @@ class ScheduleView(
@action(detail=True, methods=["get", "post", "delete"])
def export_token(self, request, pk):
schedule = self.get_object()
schedule = self.get_object(annotate=False)
if self.request.method == "GET":
try:

View file

@ -1,4 +1,5 @@
import json
from dataclasses import asdict
from django.core.exceptions import ObjectDoesNotExist
from django_filters import rest_framework as filters
@ -14,6 +15,7 @@ from apps.api.permissions import RBACPermission
from apps.api.serializers.webhook import WebhookResponseSerializer, WebhookSerializer
from apps.auth_token.auth import PluginAuthentication
from apps.webhooks.models import Webhook, WebhookResponse
from apps.webhooks.presets.preset_options import WebhookPresetOptions
from apps.webhooks.utils import apply_jinja_template_for_json
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.filters import ByTeamModelFieldFilterMixin, ModelFieldFilterMixin, TeamModelMultipleChoiceFilter
@ -52,6 +54,7 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
"destroy": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
"responses": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
"preview_template": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_WRITE],
"preset_options": [RBACPermission.Permissions.OUTGOING_WEBHOOKS_READ],
}
model = Webhook
@ -179,3 +182,8 @@ class WebhooksView(TeamFilteringMixin, PublicPrimaryKeyMixin, ModelViewSet):
response = {"preview": result}
return Response(response, status=status.HTTP_200_OK)
@action(methods=["get"], detail=False)
def preset_options(self, request):
result = [asdict(preset) for preset in WebhookPresetOptions.WEBHOOK_PRESET_CHOICES]
return Response(result)

View file

@ -180,14 +180,12 @@ class IntegrationRateLimitMixin(RateLimitMixin, View):
"because too many alerts were sent from your {integration} integration. "
"Rate-limiting is activated so you will continue to receive alerts from other integrations. "
"Read more about rate limits in our docs. "
"To increase your capacity, reach out to our support team."
)
TEXT_WORKSPACE = (
"Rate-limiting has been applied to your account "
"because too many alerts were sent from multiple integrations. "
"Read more about rate limits in our docs. "
"To increase your capacity, reach out to our support team."
)
@ratelimit(

View file

@ -9,7 +9,7 @@ from common.api_helpers.utils import CurrentTeamDefault
class ActionCreateSerializer(WebhookCreateSerializer):
team_id = TeamPrimaryKeyRelatedField(allow_null=True, default=CurrentTeamDefault(), source="team")
user = serializers.CharField(required=False, source="username")
user = serializers.CharField(required=False, source="username", allow_null=True, allow_blank=True)
trigger_type = WebhookTriggerTypeField(required=False)
forward_whole_payload = serializers.BooleanField(required=False, source="forward_all")
@ -23,7 +23,6 @@ class ActionCreateSerializer(WebhookCreateSerializer):
"team_id",
"user",
"data",
"user",
"password",
"authorization_header",
"trigger_template",
@ -37,6 +36,12 @@ class ActionCreateSerializer(WebhookCreateSerializer):
extra_kwargs = {
"name": {"required": True, "allow_null": False, "allow_blank": False},
"url": {"required": True, "allow_null": False, "allow_blank": False},
"data": {"required": False, "allow_null": True, "allow_blank": True},
"password": {"required": False, "allow_null": True, "allow_blank": True},
"authorization_header": {"required": False, "allow_null": True, "allow_blank": True},
"trigger_template": {"required": False, "allow_null": True, "allow_blank": True},
"headers": {"required": False, "allow_null": True, "allow_blank": True},
"integration_filter": {"required": False, "allow_null": True},
}
validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])]
@ -51,13 +56,13 @@ class ActionUpdateSerializer(ActionCreateSerializer):
extra_kwargs = {
"name": {"required": False, "allow_null": False, "allow_blank": False},
"is_webhook_enabled": {"required": False, "allow_null": False},
"user": {"required": False, "allow_null": True, "allow_blank": False},
"password": {"required": False, "allow_null": True, "allow_blank": False},
"authorization_header": {"required": False, "allow_null": True, "allow_blank": False},
"trigger_template": {"required": False, "allow_null": True, "allow_blank": False},
"headers": {"required": False, "allow_null": True, "allow_blank": False},
"user": {"required": False, "allow_null": True, "allow_blank": True},
"password": {"required": False, "allow_null": True, "allow_blank": True},
"authorization_header": {"required": False, "allow_null": True, "allow_blank": True},
"trigger_template": {"required": False, "allow_null": True, "allow_blank": True},
"headers": {"required": False, "allow_null": True, "allow_blank": True},
"url": {"required": False, "allow_null": False, "allow_blank": False},
"data": {"required": False, "allow_null": True, "allow_blank": False},
"data": {"required": False, "allow_null": True, "allow_blank": True},
"forward_whole_payload": {"required": False, "allow_null": False},
"http_method": {"required": False, "allow_null": False, "allow_blank": False},
"integration_filter": {"required": False, "allow_null": True},

View file

@ -28,9 +28,11 @@ class ScheduleCalendarSerializer(ScheduleBaseSerializer):
"on_call_now",
"shifts",
"ical_url_overrides",
"enable_web_overrides",
]
extra_kwargs = {
"ical_url_overrides": {"required": False, "allow_null": True},
"enable_web_overrides": {"required": False, "allow_null": True},
}
def validate_shifts(self, shifts):
@ -61,10 +63,12 @@ class ScheduleCalendarUpdateSerializer(ScheduleCalendarSerializer):
"on_call_now",
"shifts",
"ical_url_overrides",
"enable_web_overrides",
]
extra_kwargs = {
"name": {"required": False},
"ical_url_overrides": {"required": False, "allow_null": True},
"enable_web_overrides": {"required": False, "allow_null": True},
}
def update(self, instance, validated_data):

View file

@ -12,6 +12,8 @@ from common.api_helpers.utils import CurrentOrganizationDefault, CurrentTeamDefa
from common.jinja_templater import apply_jinja_template
from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning
PRESET_VALIDATION_MESSAGE = "Preset webhooks must be modified through web UI"
INTEGRATION_FILTER_MESSAGE = "integration_filter must be a list of valid integration ids"
@ -73,11 +75,20 @@ class WebhookCreateSerializer(serializers.ModelSerializer):
"http_method",
"trigger_type",
"integration_filter",
"preset",
]
extra_kwargs = {
"name": {"required": True, "allow_null": False, "allow_blank": False},
"url": {"required": True, "allow_null": False, "allow_blank": False},
"http_method": {"required": True, "allow_null": False, "allow_blank": False},
"username": {"required": False, "allow_null": True, "allow_blank": True},
"password": {"required": False, "allow_null": True, "allow_blank": True},
"authorization_header": {"required": False, "allow_null": True, "allow_blank": True},
"trigger_template": {"required": False, "allow_null": True, "allow_blank": True},
"headers": {"required": False, "allow_null": True, "allow_blank": True},
"data": {"required": False, "allow_null": True, "allow_blank": True},
"forward_all": {"required": False, "allow_null": False},
"integration_filter": {"required": False, "allow_null": True},
}
validators = [UniqueTogetherValidator(queryset=Webhook.objects.all(), fields=["name", "organization"])]
@ -149,6 +160,14 @@ class WebhookCreateSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(INTEGRATION_FILTER_MESSAGE)
return integration_filter
def validate_preset(self, preset):
raise serializers.ValidationError(PRESET_VALIDATION_MESSAGE)
def validate(self, data):
if self.instance and self.instance.preset:
raise serializers.ValidationError(PRESET_VALIDATION_MESSAGE)
return data
class WebhookUpdateSerializer(WebhookCreateSerializer):
trigger_type = WebhookTriggerTypeField(required=False)
@ -157,13 +176,13 @@ class WebhookUpdateSerializer(WebhookCreateSerializer):
extra_kwargs = {
"name": {"required": False, "allow_null": False, "allow_blank": False},
"is_webhook_enabled": {"required": False, "allow_null": False},
"username": {"required": False, "allow_null": True, "allow_blank": False},
"password": {"required": False, "allow_null": True, "allow_blank": False},
"authorization_header": {"required": False, "allow_null": True, "allow_blank": False},
"trigger_template": {"required": False, "allow_null": True, "allow_blank": False},
"headers": {"required": False, "allow_null": True, "allow_blank": False},
"username": {"required": False, "allow_null": True, "allow_blank": True},
"password": {"required": False, "allow_null": True, "allow_blank": True},
"authorization_header": {"required": False, "allow_null": True, "allow_blank": True},
"trigger_template": {"required": False, "allow_null": True, "allow_blank": True},
"headers": {"required": False, "allow_null": True, "allow_blank": True},
"url": {"required": False, "allow_null": False, "allow_blank": False},
"data": {"required": False, "allow_null": True, "allow_blank": False},
"data": {"required": False, "allow_null": True, "allow_blank": True},
"forward_all": {"required": False, "allow_null": False},
"http_method": {"required": False, "allow_null": False, "allow_blank": False},
"integration_filter": {"required": False, "allow_null": True},

View file

@ -160,17 +160,44 @@ def test_get_custom_action(
@pytest.mark.django_db
def test_create_custom_action(make_organization_and_user_with_token):
@pytest.mark.parametrize(
"data",
[
(
{
"name": "Test outgoing webhook",
"url": "https://example.com",
}
),
(
{
"name": "Test outgoing webhook",
"url": "https://example.com",
"user": None,
"password": None,
"data": None,
"authorization_header": None,
"forward_whole_payload": True,
}
),
(
{
"name": "Test outgoing webhook",
"url": "https://example.com",
"user": "",
"password": "",
"data": "",
"authorization_header": "",
"forward_whole_payload": True,
}
),
],
)
def test_create_custom_action(make_organization_and_user_with_token, data):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
url = reverse("api-public:actions-list")
data = {
"name": "Test outgoing webhook",
"url": "https://example.com",
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
custom_action = Webhook.objects.get(public_primary_key=response.data["id"])

View file

@ -94,6 +94,7 @@ def test_get_calendar_schedule(
"user_group_id": None,
},
"ical_url_overrides": None,
"enable_web_overrides": False,
}
assert response.status_code == status.HTTP_200_OK
@ -130,6 +131,7 @@ def test_create_calendar_schedule(make_organization_and_user_with_token):
"user_group_id": None,
},
"ical_url_overrides": None,
"enable_web_overrides": False,
}
assert response.status_code == status.HTTP_201_CREATED
@ -180,6 +182,7 @@ def test_create_calendar_schedule_with_shifts(make_organization_and_user_with_to
"user_group_id": None,
},
"ical_url_overrides": None,
"enable_web_overrides": False,
}
assert response.status_code == status.HTTP_201_CREATED
@ -227,6 +230,7 @@ def test_update_calendar_schedule(
"user_group_id": None,
},
"ical_url_overrides": None,
"enable_web_overrides": False,
}
assert response.status_code == status.HTTP_200_OK
@ -236,6 +240,45 @@ def test_update_calendar_schedule(
assert response.json() == result
@pytest.mark.django_db
def test_update_calendar_schedule_enable_web_overrides(
make_organization_and_user_with_token,
make_schedule,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleCalendar,
)
url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key})
data = {
"enable_web_overrides": True,
}
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
result = {
"id": schedule.public_primary_key,
"team_id": None,
"name": schedule.name,
"type": "calendar",
"time_zone": "UTC",
"on_call_now": [],
"shifts": [],
"slack": {"channel_id": None, "user_group_id": None},
"ical_url_overrides": None,
"enable_web_overrides": True,
}
assert response.status_code == status.HTTP_200_OK
schedule.refresh_from_db()
assert schedule.enable_web_overrides
assert response.json() == result
@pytest.mark.django_db
def test_get_web_schedule(
make_organization_and_user_with_token,
@ -363,6 +406,7 @@ def test_update_ical_url_overrides_calendar_schedule(
"user_group_id": None,
},
"ical_url_overrides": ICAL_URL,
"enable_web_overrides": False,
}
assert response.status_code == status.HTTP_200_OK
@ -418,6 +462,7 @@ def test_update_calendar_schedule_with_custom_event(
"user_group_id": None,
},
"ical_url_overrides": None,
"enable_web_overrides": False,
}
assert response.status_code == status.HTTP_200_OK
@ -732,6 +777,7 @@ def test_get_schedule_list(
"shifts": [],
"slack": {"channel_id": slack_channel_id, "user_group_id": user_group_id},
"ical_url_overrides": None,
"enable_web_overrides": False,
},
{
"id": schedule_ical.public_primary_key,

View file

@ -5,7 +5,9 @@ from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.public_api.serializers.webhooks import PRESET_VALIDATION_MESSAGE
from apps.webhooks.models import Webhook
from apps.webhooks.tests.test_webhook_presets import TEST_WEBHOOK_PRESET_ID
def _get_expected_result(webhook):
@ -25,6 +27,7 @@ def _get_expected_result(webhook):
"http_method": webhook.http_method,
"trigger_type": Webhook.PUBLIC_TRIGGER_TYPES_MAP[webhook.trigger_type],
"integration_filter": webhook.integration_filter,
"preset": webhook.preset,
}
@ -163,6 +166,45 @@ def test_create_webhook(make_organization_and_user_with_token):
assert response.data == expected_result
@pytest.mark.django_db
@pytest.mark.parametrize(
"optional_value",
[
None,
"",
],
)
def test_create_webhook_optional_fields(make_organization_and_user_with_token, optional_value):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
url = reverse("api-public:webhooks-list")
data = {
"name": "Test outgoing webhook with nested data",
"url": "https://example.com",
"http_method": "POST",
"trigger_type": "acknowledge",
"data": optional_value,
"username": optional_value,
"password": optional_value,
"authorization_header": optional_value,
"trigger_template": optional_value,
"headers": optional_value,
"forward_all": True,
"is_webhook_enabled": True,
"integration_filter": optional_value,
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
webhook = Webhook.objects.get(public_primary_key=response.data["id"])
expected_result = _get_expected_result(webhook)
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == expected_result
@pytest.mark.django_db
def test_create_webhook_nested_data(make_organization_and_user_with_token):
organization, user, token = make_organization_and_user_with_token()
@ -318,3 +360,70 @@ def test_webhook_validate_integration_filters(
assert response.status_code == 200
assert response.data["integration_filter"] == data["integration_filter"]
assert webhook.integration_filter == data["integration_filter"]
@pytest.mark.django_db
def test_get_webhook_with_preset(
make_organization_and_user_with_token,
make_custom_webhook,
webhook_preset_api_setup,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
webhook = make_custom_webhook(organization=organization, preset=TEST_WEBHOOK_PRESET_ID)
url = reverse("api-public:webhooks-list")
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
expected_payload = {
"count": 1,
"next": None,
"previous": None,
"results": [_get_expected_result(webhook)],
"current_page_number": 1,
"page_size": 50,
"total_pages": 1,
}
assert response.status_code == status.HTTP_200_OK
assert response.data == expected_payload
@pytest.mark.django_db
def test_webhook_block_preset_create(
make_organization_and_user_with_token,
webhook_preset_api_setup,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
url = reverse("api-public:webhooks-list")
data = {
"name": "Test outgoing webhook with nested data",
"trigger_type": "acknowledge",
"preset": TEST_WEBHOOK_PRESET_ID,
}
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["preset"][0] == PRESET_VALIDATION_MESSAGE
@pytest.mark.django_db
def test_webhook_block_preset_update(
make_organization_and_user_with_token,
make_custom_webhook,
webhook_preset_api_setup,
):
organization, user, token = make_organization_and_user_with_token()
client = APIClient()
webhook = make_custom_webhook(organization=organization, preset=TEST_WEBHOOK_PRESET_ID)
webhook.refresh_from_db()
url = reverse("api-public:webhooks-detail", kwargs={"pk": webhook.public_primary_key})
data = {
"name": "Test rename preset webhook",
}
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["non_field_errors"][0] == PRESET_VALIDATION_MESSAGE

View file

@ -67,11 +67,10 @@ IcalEvents = typing.List[IcalEvent]
def users_in_ical(
usernames_from_ical: typing.List[str],
organization: "Organization",
) -> "UserQuerySet":
) -> typing.List["User"]:
"""
This method returns a sequence of `User` objects, filtered by users whose username, or case-insensitive e-mail,
is present in `usernames_from_ical`. If `include_viewers` is set to `True`, users are further filtered down
based on their granted permissions.
is present in `usernames_from_ical`.
Parameters
----------
@ -80,24 +79,26 @@ def users_in_ical(
organization : apps.user_management.models.organization.Organization
The organization in question
"""
from apps.user_management.models import User
required_permission = RBACPermission.Permissions.SCHEDULES_WRITE
emails_from_ical = [username.lower() for username in usernames_from_ical]
# users_found_in_ical = organization.users
users_found_in_ical = organization.users.filter(
**User.build_permissions_query(RBACPermission.Permissions.SCHEDULES_WRITE, organization)
)
users_found_in_ical = users_found_in_ical.filter(
(Q(username__in=usernames_from_ical) | Q(email__lower__in=emails_from_ical))
).distinct()
return users_found_in_ical
if organization.is_rbac_permissions_enabled:
# it is more efficient to check permissions on the subset of users filtered above
# than performing a regex query for the required permission
users_found_in_ical = [u for u in users_found_in_ical if {"action": required_permission.value} in u.permissions]
else:
users_found_in_ical = users_found_in_ical.filter(role__lte=required_permission.fallback_role.value)
return list(users_found_in_ical)
@timed_lru_cache(timeout=100)
def memoized_users_in_ical(usernames_from_ical: typing.List[str], organization: "Organization") -> UserQuerySet:
def memoized_users_in_ical(usernames_from_ical: typing.List[str], organization: "Organization") -> typing.List["User"]:
# using in-memory cache instead of redis to avoid pickling python objects
return users_in_ical(usernames_from_ical, organization)
@ -354,7 +355,7 @@ def list_users_to_notify_from_ical_for_period(
schedule: "OnCallSchedule",
start_datetime: datetime.datetime,
end_datetime: datetime.datetime,
) -> UserQuerySet:
) -> typing.List["User"]:
users_found_in_ical: typing.Sequence["User"] = []
events = schedule.final_events(start_datetime, end_datetime)
usernames = []
@ -366,18 +367,18 @@ def list_users_to_notify_from_ical_for_period(
def get_oncall_users_for_multiple_schedules(
schedules: "OnCallScheduleQuerySet", events_datetime=None
schedules: typing.List["OnCallSchedule"], events_datetime=None
) -> typing.Dict["OnCallSchedule", UserQuerySet]:
if events_datetime is None:
events_datetime = datetime.datetime.now(timezone.utc)
# Exit early if there are no schedules
if not schedules.exists():
if not schedules:
return {}
# Get on-call users
oncall_users = {}
for schedule in schedules.all():
for schedule in schedules:
# pass user list to list_users_to_notify_from_ical
schedule_oncall_users = list_users_to_notify_from_ical(schedule, events_datetime=events_datetime)
oncall_users.update({schedule.pk: schedule_oncall_users})

View file

@ -151,7 +151,7 @@ def generate_public_primary_key_for_oncall_schedule_channel():
class OnCallScheduleQuerySet(PolymorphicQuerySet):
def get_oncall_users(self, events_datetime=None):
return get_oncall_users_for_multiple_schedules(self, events_datetime)
return get_oncall_users_for_multiple_schedules(self.all(), events_datetime)
def related_to_user(self, user):
username_regex = RE_ICAL_SEARCH_USERNAME.format(user.username)

View file

@ -0,0 +1,21 @@
# Generated by Django 3.2.20 on 2023-09-26 22:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user_management', '0014_auto_20230728_0802'),
]
operations = [
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['is_active', 'organization', 'username'], name='user_manage_is_acti_385fc4_idx'),
),
migrations.AddIndex(
model_name='user',
index=models.Index(fields=['is_active', 'organization', 'email'], name='user_manage_is_acti_7e930d_idx'),
),
]

View file

@ -169,6 +169,10 @@ class User(models.Model):
# Including is_active to unique_together and setting is_active to None allows to
# have multiple deleted users with the same user_id, but user_id is unique among active users
unique_together = ("user_id", "organization", "is_active")
indexes = [
models.Index(fields=["is_active", "organization", "username"]),
models.Index(fields=["is_active", "organization", "email"]),
]
public_primary_key = models.CharField(
max_length=20,

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.20 on 2023-09-20 18:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('webhooks', '0010_alter_webhook_trigger_type'),
]
operations = [
migrations.AddField(
model_name='webhook',
name='preset',
field=models.CharField(blank=True, default=None, max_length=100, null=True),
),
migrations.AlterField(
model_name='webhook',
name='http_method',
field=models.CharField(default='POST', max_length=32, null=True),
),
]

View file

@ -94,6 +94,8 @@ class Webhook(models.Model):
(TRIGGER_UNACKNOWLEDGE, "Unacknowledged"),
)
ALL_TRIGGER_TYPES = [i[0] for i in TRIGGER_TYPES]
PUBLIC_TRIGGER_TYPES_MAP = {
TRIGGER_ESCALATION_STEP: "escalation",
TRIGGER_ALERT_GROUP_CREATED: "alert group created",
@ -137,11 +139,12 @@ class Webhook(models.Model):
url = models.TextField(null=True, default=None)
data = models.TextField(null=True, default=None)
forward_all = models.BooleanField(default=True)
http_method = models.CharField(max_length=32, default="POST")
http_method = models.CharField(max_length=32, default="POST", null=True)
trigger_type = models.IntegerField(choices=TRIGGER_TYPES, default=TRIGGER_ESCALATION_STEP, null=True)
is_webhook_enabled = models.BooleanField(null=True, default=True)
integration_filter = models.JSONField(default=None, null=True, blank=True)
is_legacy = models.BooleanField(null=True, default=False)
preset = models.CharField(max_length=100, null=True, blank=True, default=None)
class Meta:
unique_together = ("name", "organization")

View file

View file

@ -0,0 +1,19 @@
from apps.webhooks.models import Webhook
from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata
class AdvancedWebhookPreset(WebhookPreset):
def _metadata(self) -> WebhookPresetMetadata:
return WebhookPresetMetadata(
id="advanced_webhook",
name="Advanced",
logo="webhook",
description="An advanced webhook with all available settings and template options.",
controlled_fields=[],
)
def override_parameters_before_save(self, webhook: Webhook):
pass
def override_parameters_at_runtime(self, webhook: Webhook):
pass

View file

@ -0,0 +1,36 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List
from django.utils.functional import cached_property
from apps.webhooks.models import Webhook
@dataclass
class WebhookPresetMetadata:
id: str
name: str
logo: str
description: str
controlled_fields: List[str]
class WebhookPreset(ABC):
@cached_property
def metadata(self) -> WebhookPresetMetadata:
return self._metadata()
@abstractmethod
def _metadata(self) -> WebhookPresetMetadata:
raise NotImplementedError
@abstractmethod
def override_parameters_before_save(self, webhook: Webhook):
"""Implement this to write parameters before the webhook is saved to the database"""
pass
@abstractmethod
def override_parameters_at_runtime(self, webhook: Webhook):
"""Implement this to write parameters before the webhook is executed (These will not be persisted)"""
pass

View file

@ -0,0 +1,30 @@
from importlib import import_module
from django.conf import settings
from django.db.models.signals import pre_save
from django.dispatch import receiver
from apps.webhooks.models import Webhook
class WebhookPresetOptions:
WEBHOOK_PRESETS = {}
for webhook_preset_config in settings.INSTALLED_WEBHOOK_PRESETS:
module_path, class_name = webhook_preset_config.rsplit(".", 1)
module = import_module(module_path)
preset = getattr(module, class_name)()
WEBHOOK_PRESETS[preset.metadata.id] = preset
WEBHOOK_PRESET_CHOICES = [webhook_preset.metadata for webhook_preset in WEBHOOK_PRESETS.values()]
@receiver(pre_save, sender=Webhook)
def listen_for_webhook_save(sender: Webhook, instance: Webhook, raw: bool, *args, **kwargs) -> None:
if instance.preset and not instance.deleted_at:
if instance.preset in WebhookPresetOptions.WEBHOOK_PRESETS:
WebhookPresetOptions.WEBHOOK_PRESETS[instance.preset].override_parameters_before_save(instance)
else:
raise NotImplementedError(f"Webhook references unknown preset implementation {instance.preset}")
pre_save.connect(listen_for_webhook_save, Webhook)

View file

@ -0,0 +1,32 @@
from apps.webhooks.models import Webhook
from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata
class SimpleWebhookPreset(WebhookPreset):
def _metadata(self) -> WebhookPresetMetadata:
return WebhookPresetMetadata(
id="simple_webhook",
name="Simple",
logo="webhook",
description="A simple webhook which sends the alert group data to a given URL. Triggered as an escalation step.",
controlled_fields=[
"trigger_type",
"http_method",
"integration_filter",
"headers",
"username",
"password",
"authorization_header",
"trigger_template",
"forward_all",
"data",
],
)
def override_parameters_before_save(self, webhook: Webhook):
webhook.http_method = "POST"
webhook.trigger_type = Webhook.TRIGGER_ESCALATION_STEP
webhook.forward_all = True
def override_parameters_at_runtime(self, webhook: Webhook):
pass

View file

@ -11,6 +11,7 @@ from apps.base.models import UserNotificationPolicyLogRecord
from apps.user_management.models import User
from apps.webhooks.models import Webhook, WebhookResponse
from apps.webhooks.models.webhook import WEBHOOK_FIELD_PLACEHOLDER
from apps.webhooks.presets.preset_options import WebhookPresetOptions
from apps.webhooks.utils import (
InvalidWebhookData,
InvalidWebhookHeaders,
@ -116,6 +117,12 @@ def make_request(webhook, alert_group, data):
exception = error = None
try:
if webhook.preset:
if webhook.preset not in WebhookPresetOptions.WEBHOOK_PRESETS:
raise Exception(f"Invalid preset {webhook.preset}")
else:
WebhookPresetOptions.WEBHOOK_PRESETS[webhook.preset].override_parameters_at_runtime(webhook)
if not webhook.check_integration_filter(alert_group):
status["request_trigger"] = NOT_FROM_SELECTED_INTEGRATION
return False, status, None, None
@ -168,7 +175,7 @@ def execute_webhook(webhook_pk, alert_group_id, user_id, escalation_policy_id):
try:
webhook = Webhook.objects.get(pk=webhook_pk)
except Webhook.DoesNotExist:
logger.warn(f"Webhook {webhook_pk} does not exist")
logger.warning(f"Webhook {webhook_pk} does not exist")
return
try:

View file

@ -0,0 +1,160 @@
from unittest.mock import patch
import pytest
from apps.webhooks.models import Webhook
from apps.webhooks.presets.preset import WebhookPreset, WebhookPresetMetadata
from apps.webhooks.tasks.trigger_webhook import make_request
from apps.webhooks.tests.test_trigger_webhook import MockResponse
TEST_WEBHOOK_PRESET_URL = "https://test123.com"
TEST_WEBHOOK_PRESET_NAME = "Test Webhook"
TEST_WEBHOOK_PRESET_ID = "test_webhook"
TEST_WEBHOOK_LOGO = "test_logo"
TEST_WEBHOOK_PRESET_DESCRIPTION = "Description of test webhook preset"
TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS = ["url", "http_method", "data", "authorization_header"]
TEST_WEBHOOK_AUTHORIZATION_HEADER = "Test Auth header 12345"
INVALID_PRESET_ID = "invalid_preset_id"
class TestWebhookPreset(WebhookPreset):
def _metadata(self) -> WebhookPresetMetadata:
return WebhookPresetMetadata(
id=TEST_WEBHOOK_PRESET_ID,
name=TEST_WEBHOOK_PRESET_NAME,
logo=TEST_WEBHOOK_LOGO,
description=TEST_WEBHOOK_PRESET_DESCRIPTION,
controlled_fields=TEST_WEBHOOK_PRESET_CONTROLLED_FIELDS,
)
def override_parameters_before_save(self, webhook: Webhook):
webhook.data = webhook.organization.org_title
webhook.url = TEST_WEBHOOK_PRESET_URL
webhook.http_method = "GET"
def override_parameters_at_runtime(self, webhook: Webhook):
webhook.authorization_header = TEST_WEBHOOK_AUTHORIZATION_HEADER
@pytest.mark.django_db
def test_create_webhook_from_preset(make_organization, webhook_preset_api_setup, make_custom_webhook):
organization = make_organization()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)
webhook.refresh_from_db()
assert webhook.url == TEST_WEBHOOK_PRESET_URL
assert webhook.http_method == "GET"
assert webhook.data == organization.org_title
assert webhook.authorization_header is None
@pytest.mark.django_db
def test_create_webhook_from_invalid_preset(make_organization, webhook_preset_api_setup, make_custom_webhook):
organization = make_organization()
expected = None
try:
make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=INVALID_PRESET_ID,
)
except NotImplementedError as e:
expected = e
assert expected.args[0] == f"Webhook references unknown preset implementation {INVALID_PRESET_ID}"
@pytest.mark.django_db
def test_update_webhook_from_preset(make_organization, webhook_preset_api_setup, make_custom_webhook):
organization = make_organization()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)
webhook.refresh_from_db()
webhook.http_method = "POST"
webhook.save()
webhook.refresh_from_db()
assert webhook.http_method == "GET"
@pytest.mark.django_db
def test_update_webhook_from_invalid_preset(make_organization, webhook_preset_api_setup, make_custom_webhook):
organization = make_organization()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)
webhook.refresh_from_db()
webhook.preset = INVALID_PRESET_ID
try:
webhook.save()
except NotImplementedError as e:
expected = e
assert expected.args[0] == f"Webhook references unknown preset implementation {INVALID_PRESET_ID}"
webhook.refresh_from_db()
assert webhook.preset == TEST_WEBHOOK_PRESET_ID
@pytest.mark.django_db
def test_webhook_preset_runtime_override(make_organization, webhook_preset_api_setup, make_custom_webhook):
organization = make_organization()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
preset=TEST_WEBHOOK_PRESET_ID,
)
with patch.object(webhook, "build_url"):
response = MockResponse()
with patch.object(webhook, "make_request", return_value=response) as mock_make_request:
triggered, webhook_status, error, exception = make_request(webhook, None, None)
assert mock_make_request.call_args.args[1]["headers"]["Authorization"] == TEST_WEBHOOK_AUTHORIZATION_HEADER
assert triggered
assert error is None
assert exception is None
webhook.refresh_from_db()
assert webhook.authorization_header is None
@pytest.mark.django_db
def test_webhook_invalid_preset_runtime_override(make_organization, webhook_preset_api_setup, make_custom_webhook):
organization = make_organization()
webhook = make_custom_webhook(
name="the_webhook",
organization=organization,
trigger_type=Webhook.TRIGGER_ALERT_GROUP_CREATED,
)
webhook.refresh_from_db()
expected_error = f"Invalid preset {INVALID_PRESET_ID}"
Webhook.objects.filter(id=webhook.id).update(preset=INVALID_PRESET_ID)
webhook.refresh_from_db()
with patch.object(webhook, "build_url"):
with patch.object(webhook, "make_request") as mock_make_request:
triggered, webhook_status, error, exception = make_request(webhook, None, None)
mock_make_request.assert_not_called()
assert triggered
assert webhook_status["content"] == expected_error
assert error == expected_error
assert exception.args[0] == expected_error
webhook.refresh_from_db()
assert webhook.authorization_header is None

View file

@ -150,9 +150,11 @@ _MT = typing.TypeVar("_MT", bound=models.Model)
class PublicPrimaryKeyMixin(typing.Generic[_MT]):
def get_object(self) -> _MT:
def get_object(self, queryset_kwargs=None) -> _MT:
pk = self.kwargs["pk"]
queryset = self.filter_queryset(self.get_queryset())
if queryset_kwargs is None:
queryset_kwargs = {}
queryset = self.filter_queryset(self.get_queryset(**queryset_kwargs))
try:
obj = queryset.get(public_primary_key=pk)

View file

@ -86,7 +86,9 @@ from apps.telegram.tests.factories import (
)
from apps.user_management.models.user import User, listen_for_user_model_save
from apps.user_management.tests.factories import OrganizationFactory, RegionFactory, TeamFactory, UserFactory
from apps.webhooks.presets.preset_options import WebhookPresetOptions
from apps.webhooks.tests.factories import CustomWebhookFactory, WebhookResponseFactory
from apps.webhooks.tests.test_webhook_presets import TEST_WEBHOOK_PRESET_ID, TestWebhookPreset
register(OrganizationFactory)
register(UserFactory)
@ -907,3 +909,11 @@ def shift_swap_request_setup(
return ssr, beneficiary, benefactor
return _shift_swap_request_setup
@pytest.fixture()
def webhook_preset_api_setup():
WebhookPresetOptions.WEBHOOK_PRESETS = {TEST_WEBHOOK_PRESET_ID: TestWebhookPreset()}
WebhookPresetOptions.WEBHOOK_PRESET_CHOICES = [
preset.metadata for preset in WebhookPresetOptions.WEBHOOK_PRESETS.values()
]

View file

@ -723,6 +723,11 @@ INSTALLED_ONCALL_INTEGRATIONS = [
"config_integrations.direct_paging",
]
INSTALLED_WEBHOOK_PRESETS = [
"apps.webhooks.presets.simple.SimpleWebhookPreset",
"apps.webhooks.presets.advanced.AdvancedWebhookPreset",
]
if IS_OPEN_SOURCE:
INSTALLED_APPS += ["apps.oss_installation", "apps.zvonok"] # noqa

View file

@ -6,7 +6,7 @@ module.exports = {
plugins: ['rulesdir', 'import'],
settings: {
'import/internal-regex':
'^assets|^components|^containers|^icons|^models|^network|^pages|^services|^state|^utils|^plugin',
'^assets|^components|^containers|^contexts|^icons|^models|^network|^pages|^services|^state|^utils|^plugin',
},
rules: {
eqeqeq: 'warn',

View file

@ -56,7 +56,7 @@ const ScheduleUserDetails: FC<ScheduleUserDetailsProps> = (props) => {
<Text type="primary">{user.username}</Text>
</div>
<HorizontalGroup spacing="xs">
{isOncall && <Badge text="OnCall" color="green" />}
{isOncall && <Badge text="On-call" color="green" />}
{isInWH ? (
<Badge text="Inside working hours" color="blue" />
) : (

View file

@ -1,14 +1,13 @@
import React, { FC } from 'react';
import { NavModelItem } from '@grafana/data';
import { PluginPage } from 'PluginPage';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import { AppRootProps } from 'types';
import Alerts from 'containers/Alerts/Alerts';
import { pages } from 'pages';
import { isTopNavbar } from 'plugin/GrafanaPluginRootPage.helpers';
import { DEFAULT_PAGE } from 'utils/consts';
import styles from './DefaultPageLayout.module.scss';
@ -17,10 +16,11 @@ const cx = cn.bind(styles);
interface DefaultPageLayoutProps extends AppRootProps {
children?: any;
page: string;
pageNav: NavModelItem;
}
const DefaultPageLayout: FC<DefaultPageLayoutProps> = observer((props) => {
const { children, page } = props;
const { children, page, pageNav } = props;
if (isTopNavbar()) {
return renderTopNavbar();
@ -29,10 +29,8 @@ const DefaultPageLayout: FC<DefaultPageLayoutProps> = observer((props) => {
return renderLegacyNavbar();
function renderTopNavbar(): JSX.Element {
const matchingPageNav = (pages[page] || pages[DEFAULT_PAGE]).getPageNav();
return (
<PluginPage page={page} pageNav={matchingPageNav}>
<PluginPage page={page} pageNav={pageNav}>
<div className={cx('root')}>{children}</div>
</PluginPage>
);

View file

@ -0,0 +1,3 @@
import { ReactElement } from 'react';
export const commonWebhookPresetIconsConfig: { [id: string]: () => ReactElement } = {};

View file

@ -4,6 +4,7 @@ import { SelectableValue } from '@grafana/data';
import Emoji from 'react-emoji-render';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import { OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types';
import { KeyValuePair } from 'utils';
import { generateAssignToTeamInputDescription } from 'utils/consts';
@ -18,182 +19,226 @@ export const WebhookTriggerType = {
Unacknowledged: new KeyValuePair('7', 'Unacknowledged'),
};
export const form: { name: string; fields: FormItem[] } = {
name: 'OutgoingWebhook',
fields: [
{
name: 'name',
type: FormItemType.Input,
validation: { required: true },
},
{
name: 'is_webhook_enabled',
label: 'Enabled',
normalize: (value) => Boolean(value),
type: FormItemType.Switch,
},
{
name: 'team',
label: 'Assign to Team',
description: `${generateAssignToTeamInputDescription(
'Outgoing Webhooks'
)} This setting does not effect execution of the webhook.`,
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
displayField: 'name',
valueField: 'id',
showSearch: true,
allowClear: true,
export function createForm(presets: OutgoingWebhookPreset[]): { name: string; fields: FormItem[] } {
return {
name: 'OutgoingWebhook',
fields: [
{
name: 'name',
type: FormItemType.Input,
validation: { required: true },
},
},
{
name: 'trigger_type',
label: 'Trigger Type',
description: 'The type of event which will cause this webhook to execute.',
type: FormItemType.Select,
extra: {
options: [
{
value: WebhookTriggerType.EscalationStep.key,
label: WebhookTriggerType.EscalationStep.value,
},
{
value: WebhookTriggerType.AlertGroupCreated.key,
label: WebhookTriggerType.AlertGroupCreated.value,
},
{
value: WebhookTriggerType.Acknowledged.key,
label: WebhookTriggerType.Acknowledged.value,
},
{
value: WebhookTriggerType.Resolved.key,
label: WebhookTriggerType.Resolved.value,
},
{
value: WebhookTriggerType.Silenced.key,
label: WebhookTriggerType.Silenced.value,
},
{
value: WebhookTriggerType.Unsilenced.key,
label: WebhookTriggerType.Unsilenced.value,
},
{
value: WebhookTriggerType.Unresolved.key,
label: WebhookTriggerType.Unresolved.value,
},
{
value: WebhookTriggerType.Unacknowledged.key,
label: WebhookTriggerType.Unacknowledged.value,
},
],
{
name: 'is_webhook_enabled',
label: 'Enabled',
normalize: (value) => Boolean(value),
type: FormItemType.Switch,
},
validation: { required: true },
normalize: (value) => value,
},
{
name: 'http_method',
label: 'HTTP Method',
type: FormItemType.Select,
extra: {
options: [
{
value: 'GET',
label: 'GET',
},
{
value: 'POST',
label: 'POST',
},
{
value: 'PUT',
label: 'PUT',
},
{
value: 'DELETE',
label: 'DELETE',
},
{
value: 'OPTIONS',
label: 'OPTIONS',
},
],
{
name: 'team',
label: 'Assign to Team',
description: `${generateAssignToTeamInputDescription(
'Outgoing Webhooks'
)} This setting does not effect execution of the webhook.`,
type: FormItemType.GSelect,
extra: {
modelName: 'grafanaTeamStore',
displayField: 'name',
valueField: 'id',
showSearch: true,
allowClear: true,
placeholder: 'Choose (Optional)',
},
},
validation: { required: true },
normalize: (value) => value,
},
{
name: 'integration_filter',
label: 'Integrations',
type: FormItemType.MultiSelect,
isVisible: (data) => {
return data.trigger_type !== WebhookTriggerType.EscalationStep.key;
{
name: 'trigger_type',
label: 'Trigger Type',
description: 'The type of event which will cause this webhook to execute.',
type: FormItemType.Select,
extra: {
placeholder: 'Choose (Required)',
options: [
{
value: WebhookTriggerType.EscalationStep.key,
label: WebhookTriggerType.EscalationStep.value,
},
{
value: WebhookTriggerType.AlertGroupCreated.key,
label: WebhookTriggerType.AlertGroupCreated.value,
},
{
value: WebhookTriggerType.Acknowledged.key,
label: WebhookTriggerType.Acknowledged.value,
},
{
value: WebhookTriggerType.Resolved.key,
label: WebhookTriggerType.Resolved.value,
},
{
value: WebhookTriggerType.Silenced.key,
label: WebhookTriggerType.Silenced.value,
},
{
value: WebhookTriggerType.Unsilenced.key,
label: WebhookTriggerType.Unsilenced.value,
},
{
value: WebhookTriggerType.Unresolved.key,
label: WebhookTriggerType.Unresolved.value,
},
{
value: WebhookTriggerType.Unacknowledged.key,
label: WebhookTriggerType.Unacknowledged.value,
},
],
},
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'trigger_type');
},
normalize: (value) => value,
},
extra: {
modelName: 'alertReceiveChannelStore',
displayField: 'verbal_name',
valueField: 'id',
showSearch: true,
getOptionLabel: (item: SelectableValue) => <Emoji text={item?.label || ''} />,
{
name: 'http_method',
label: 'HTTP Method',
type: FormItemType.Select,
extra: {
placeholder: 'Choose (Required)',
options: [
{
value: 'GET',
label: 'GET',
},
{
value: 'POST',
label: 'POST',
},
{
value: 'PUT',
label: 'PUT',
},
{
value: 'DELETE',
label: 'DELETE',
},
{
value: 'OPTIONS',
label: 'OPTIONS',
},
],
},
isVisible: (data) => isPresetFieldVisible(data.preset, presets, 'http_method'),
normalize: (value) => value,
},
validation: { required: true },
description:
'Integrations that this webhook applies to. If this is empty the webhook will execute for all integrations',
},
{
name: 'url',
label: 'Webhook URL',
type: FormItemType.Monaco,
validation: { required: true },
extra: {
height: 30,
{
name: 'integration_filter',
label: 'Integrations',
type: FormItemType.MultiSelect,
isVisible: (data) => {
return (
isPresetFieldVisible(data.preset, presets, 'integration_filter') &&
data.trigger_type !== WebhookTriggerType.EscalationStep.key
);
},
extra: {
placeholder: 'Choose (Optional)',
modelName: 'alertReceiveChannelStore',
displayField: 'verbal_name',
valueField: 'id',
showSearch: true,
getOptionLabel: (item: SelectableValue) => <Emoji text={item?.label || ''} />,
},
description:
'Integrations that this webhook applies to. If this is empty the webhook will execute for all integrations',
},
},
{
name: 'headers',
label: 'Webhook Headers',
description: 'Request headers should be in JSON format.',
type: FormItemType.Monaco,
extra: {
rows: 3,
{
name: 'url',
label: 'Webhook URL',
type: FormItemType.Monaco,
extra: {
height: 30,
},
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'url');
},
},
},
{
name: 'username',
type: FormItemType.Input,
},
{
name: 'password',
type: FormItemType.Password,
},
{
name: 'authorization_header',
description:
'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456',
type: FormItemType.Password,
},
{
name: 'trigger_template',
type: FormItemType.Monaco,
description:
'Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent',
extra: {
rows: 2,
{
name: 'headers',
label: 'Webhook Headers',
description: 'Request headers should be in JSON format.',
type: FormItemType.Monaco,
extra: {
rows: 3,
},
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'headers');
},
},
},
{
name: 'forward_all',
normalize: (value) => Boolean(value),
type: FormItemType.Switch,
description: "Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data",
},
{
name: 'data',
getDisabled: (data) => Boolean(data?.forward_all),
type: FormItemType.Monaco,
description:
'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}',
extra: {},
},
],
};
{
name: 'username',
type: FormItemType.Input,
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'username');
},
},
{
name: 'password',
type: FormItemType.Password,
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'password');
},
},
{
name: 'authorization_header',
description:
'Value of the Authorization header, do not need to prefix with "Authorization:". For example: Bearer AbCdEf123456',
type: FormItemType.Password,
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'authorization_header');
},
},
{
name: 'trigger_template',
type: FormItemType.Monaco,
description:
'Trigger template is used to conditionally execute the webhook based on incoming data. The trigger template must be empty or evaluate to true or 1 for the webhook to be sent',
extra: {
rows: 2,
},
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'trigger_template');
},
},
{
name: 'forward_all',
normalize: (value) => (value ? Boolean(value) : value),
type: FormItemType.Switch,
description: "Forwards whole payload of the alert group and context data to the webhook's url as POST/PUT data",
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'forward_all');
},
},
{
name: 'data',
getDisabled: (data) => Boolean(data?.forward_all),
type: FormItemType.Monaco,
description:
'Available variables: {{ event }}, {{ user }}, {{ alert_group }}, {{ alert_group_id }}, {{ alert_payload }}, {{ integration }}, {{ notified_users }}, {{ users_to_be_notified }}, {{ responses }}',
extra: {},
isVisible: (data) => {
return isPresetFieldVisible(data.preset, presets, 'data');
},
},
],
};
}
function isPresetFieldVisible(presetId: string, presets: OutgoingWebhookPreset[], fieldName: string) {
if (presetId == null) {
return true;
}
const selectedPreset = presets.find((item) => item.id === presetId);
if (selectedPreset && selectedPreset.controlled_fields.includes(fieldName)) {
return false;
}
return true;
}

View file

@ -3,7 +3,7 @@
}
.title {
margin: 16px 0 0 16px;
margin: 0 0 0 16px;
}
.content {
@ -28,3 +28,31 @@
.webhooks__drawerContent .cursor.monaco-mouse-cursor-text {
display: none !important;
}
.cards {
display: flex;
flex-wrap: wrap;
gap: 24px;
overflow: auto;
scroll-snap-type: y mandatory;
width: 100%;
}
.card {
width: 100%;
height: 106px;
scroll-snap-align: start;
scroll-snap-stop: normal;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
cursor: pointer;
position: relative;
gap: 20px;
}
.search-integration {
width: 100%;
margin-bottom: 24px;
}

View file

@ -1,24 +1,39 @@
import React, { useCallback, useState } from 'react';
import React, { ChangeEvent, useCallback, useState } from 'react';
import { Button, ConfirmModal, ConfirmModalProps, Drawer, HorizontalGroup, Tab, TabsBar } from '@grafana/ui';
import {
Button,
ConfirmModal,
ConfirmModalProps,
Drawer,
EmptySearchResult,
HorizontalGroup,
Input,
Tab,
TabsBar,
VerticalGroup,
} from '@grafana/ui';
import cn from 'classnames/bind';
import { observer } from 'mobx-react';
import { useHistory } from 'react-router-dom';
import Block from 'components/GBlock/Block';
import GForm from 'components/GForm/GForm';
import { FormItem, FormItemType } from 'components/GForm/GForm.types';
import IntegrationLogo from 'components/IntegrationLogo/IntegrationLogo';
import { logoCoors } from 'components/IntegrationLogo/IntegrationLogo.config';
import Text from 'components/Text/Text';
import { webhookPresetIcons } from 'containers/OutgoingWebhookForm/WebhookPresetIcons.config';
import OutgoingWebhookStatus from 'containers/OutgoingWebhookStatus/OutgoingWebhookStatus';
import WebhooksTemplateEditor from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { OutgoingWebhook, OutgoingWebhookPreset } from 'models/outgoing_webhook/outgoing_webhook.types';
import { WebhookFormActionType } from 'pages/outgoing_webhooks/OutgoingWebhooks.types';
import { useStore } from 'state/useStore';
import { KeyValuePair } from 'utils';
import { UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { form } from './OutgoingWebhookForm.config';
import { createForm } from './OutgoingWebhookForm.config';
import styles from 'containers/OutgoingWebhookForm/OutgoingWebhookForm.module.css';
@ -45,10 +60,15 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
const [activeTab, setActiveTab] = useState<string>(
action === WebhookFormActionType.EDIT_SETTINGS ? WebhookTabs.Settings.key : WebhookTabs.LastRun.key
);
const [showPresetsListDrawer, setShowPresetsListDrawer] = useState(id === 'new');
const [showCreateWebhookDrawer, setShowCreateWebhookDrawer] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<OutgoingWebhookPreset>(undefined);
const [filterValue, setFilterValue] = useState('');
const { outgoingWebhookStore } = useStore();
const isNew = action === WebhookFormActionType.NEW;
const isNewOrCopy = isNew || action === WebhookFormActionType.COPY;
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets);
const handleSubmit = useCallback(
(data: Partial<OutgoingWebhook>) => {
@ -104,10 +124,17 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
| {
is_webhook_enabled: boolean;
is_legacy: boolean;
preset: string;
};
if (isNew) {
data = { is_webhook_enabled: true, is_legacy: false };
data = {
is_webhook_enabled: true,
is_legacy: false,
preset: selectedPreset?.id,
trigger_type: null,
http_method: 'POST',
};
} else if (isNewOrCopy) {
data = { ...outgoingWebhookStore.items[id], is_legacy: false, name: '' };
} else {
@ -123,27 +150,69 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
}
const formElement = <GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />;
const createWebhookParameters = (
<>
<Drawer scrollableContent title={'New Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
<div className="webhooks__drawerContent">{renderWebhookForm()}</div>
</Drawer>
{templateToEdit && (
<WebhooksTemplateEditor
id={id}
handleSubmit={(value) => {
onFormChangeFn?.fn(value);
setTemplateToEdit(undefined);
}}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
)}
</>
);
if (action === WebhookFormActionType.NEW || action === WebhookFormActionType.COPY) {
// show just the creation form, not the tabs
const presets = outgoingWebhookStore.outgoingWebhookPresets.filter((preset: OutgoingWebhookPreset) =>
preset.name.toLowerCase().includes(filterValue.toLowerCase())
);
if (action === WebhookFormActionType.NEW) {
return (
<>
<Drawer scrollableContent title={'Create Outgoing Webhook'} onClose={onHide} closeOnMaskClick={false}>
<div className="webhooks__drawerContent">{renderWebhookForm()}</div>
</Drawer>
{templateToEdit && (
<WebhooksTemplateEditor
id={id}
handleSubmit={(value) => {
onFormChangeFn?.fn(value);
setTemplateToEdit(undefined);
}}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
{showPresetsListDrawer && (
<Drawer
scrollableContent
title="New Outgoing Webhook"
onClose={onHide}
closeOnMaskClick={false}
width="640px"
>
<div className={cx('content')}>
<VerticalGroup>
<Text type="secondary">
Outgoing webhooks can send alert data to other systems. They can be triggered by various conditions
and can use templates to transform data to fit the recipient system. Presets listed below provide a
starting point to customize these connections.
</Text>
{presets.length > 8 && (
<div className={cx('search-integration')}>
<Input
autoFocus
value={filterValue}
placeholder="Search webhook presets ..."
onChange={(e: ChangeEvent<HTMLInputElement>) => setFilterValue(e.currentTarget.value)}
/>
</div>
)}
<WebhookPresetBlocks presets={presets} onBlockClick={onBlockClick} />
</VerticalGroup>
</div>
</Drawer>
)}
{(showCreateWebhookDrawer || !showPresetsListDrawer) && createWebhookParameters}
</>
);
} else if (action === WebhookFormActionType.COPY) {
return createWebhookParameters;
}
return (
@ -200,6 +269,12 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
</>
);
function onBlockClick(preset: OutgoingWebhookPreset) {
setSelectedPreset(preset);
setShowCreateWebhookDrawer(true);
setShowPresetsListDrawer(false);
}
function renderWebhookForm() {
return (
<>
@ -207,9 +282,21 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
<GForm form={form} data={data} onSubmit={handleSubmit} onFieldRender={enrchField} />
<div className={cx('buttons')}>
<HorizontalGroup justify={'flex-end'}>
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
{id === 'new' ? (
<Button
variant="secondary"
onClick={() => {
setShowCreateWebhookDrawer(false);
setShowPresetsListDrawer(true);
}}
>
Back
</Button>
) : (
<Button variant="secondary" onClick={onHide}>
Cancel
</Button>
)}
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button form={form.name} type="submit" disabled={data.is_legacy}>
{isNewOrCopy ? 'Create' : 'Update'} Webhook
@ -232,6 +319,7 @@ interface WebhookTabsProps {
| {
is_webhook_enabled: boolean;
is_legacy: boolean;
preset: string;
};
onHide: () => void;
onUpdate: () => void;
@ -251,7 +339,8 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
formElement,
}) => {
const [confirmationModal, setConfirmationModal] = useState<ConfirmModalProps>(undefined);
const { outgoingWebhookStore } = useStore();
const form = createForm(outgoingWebhookStore.outgoingWebhookPresets);
return (
<div className={cx('tabs__content')}>
{confirmationModal && (
@ -309,4 +398,43 @@ const WebhookTabsContent: React.FC<WebhookTabsProps> = ({
);
};
const WebhookPresetBlocks: React.FC<{
presets: OutgoingWebhookPreset[];
onBlockClick: (preset: OutgoingWebhookPreset) => void;
}> = ({ presets, onBlockClick }) => {
return (
<div className={cx('cards')} data-testid="create-outgoing-webhook-modal">
{presets.length ? (
presets.map((preset) => {
let logo = <IntegrationLogo integration={{ value: 'webhook', display_name: preset.name }} scale={0.2} />;
if (preset.logo in logoCoors) {
logo = <IntegrationLogo integration={{ value: preset.logo, display_name: preset.name }} scale={0.2} />;
} else if (preset.logo in webhookPresetIcons) {
logo = webhookPresetIcons[preset.logo]();
}
return (
<Block bordered hover shadowed onClick={() => onBlockClick(preset)} key={preset.id} className={cx('card')}>
<div className={cx('card-bg')}>{logo}</div>
<div className={cx('title')}>
<VerticalGroup spacing="xs">
<HorizontalGroup>
<Text strong data-testid="webhook-preset-display-name">
{preset.name}
</Text>
</HorizontalGroup>
<Text type="secondary" size="small">
{preset.description}
</Text>
</VerticalGroup>
</div>
</Block>
);
})
) : (
<EmptySearchResult>Could not find anything matching your query</EmptySearchResult>
)}
</div>
);
};
export default OutgoingWebhookForm;

View file

@ -0,0 +1,5 @@
import { ReactElement } from 'react';
import { commonWebhookPresetIconsConfig } from './CommonWebhookPresetIcons.config';
export const webhookPresetIcons: { [id: string]: () => ReactElement } = commonWebhookPresetIconsConfig;

View file

@ -56,6 +56,7 @@
.empty {
height: 28px;
cursor: pointer;
text-align: center;
/* background: #5f505633;
border: 1px dashed #5c474d;

View file

@ -6,8 +6,9 @@ import dayjs from 'dayjs';
import hash from 'object-hash';
import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.types';
import Text from 'components/Text/Text';
import ScheduleSlot from 'containers/ScheduleSlot/ScheduleSlot';
import { Schedule, Event, RotationFormLiveParams, Shift, ShiftSwap } from 'models/schedule/schedule.types';
import { Event, RotationFormLiveParams, Shift, ShiftSwap } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import RotationTutorial from './RotationTutorial';
@ -17,7 +18,6 @@ import styles from './Rotation.module.css';
const cx = cn.bind(styles);
interface RotationProps {
scheduleId: Schedule['id'];
startMoment: dayjs.Dayjs;
currentTimezone: Timezone;
layerIndex?: number;
@ -27,6 +27,7 @@ interface RotationProps {
onClick?: (start: dayjs.Dayjs, end: dayjs.Dayjs) => void;
handleAddOverride?: (start: dayjs.Dayjs, end: dayjs.Dayjs) => void;
handleAddShiftSwap?: (id: 'new', params: Partial<ShiftSwap>) => void;
handleOpenSchedule?: (event: Event) => void;
onShiftSwapClick?: (swapId: ShiftSwap['id']) => void;
days?: number;
transparent?: boolean;
@ -35,12 +36,13 @@ interface RotationProps {
filters?: ScheduleFiltersType;
getColor?: (shiftId: Shift['id']) => string;
onSlotClick?: (event: Event) => void;
emptyText?: string;
showScheduleNameAsSlotTitle?: boolean;
}
const Rotation: FC<RotationProps> = (props) => {
const {
events,
scheduleId,
startMoment,
currentTimezone,
color: propsColor,
@ -50,11 +52,14 @@ const Rotation: FC<RotationProps> = (props) => {
onClick,
handleAddOverride,
handleAddShiftSwap,
handleOpenSchedule,
onShiftSwapClick,
simplified,
filters,
getColor,
onSlotClick,
emptyText,
showScheduleNameAsSlotTitle,
} = props;
const [animate, _setAnimate] = useState<boolean>(true);
@ -73,6 +78,10 @@ const Rotation: FC<RotationProps> = (props) => {
};
const getAddOverrideClickHandler = (scheduleEvent: Event) => {
if (simplified) {
return undefined;
}
return (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
@ -81,6 +90,10 @@ const Rotation: FC<RotationProps> = (props) => {
};
const getAddShiftSwapClickHandler = (scheduleEvent: Event) => {
if (simplified) {
return undefined;
}
return (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
@ -91,6 +104,18 @@ const Rotation: FC<RotationProps> = (props) => {
};
};
const getOpenScheduleClickHandler = (scheduleEvent: Event) => {
if (!handleOpenSchedule) {
return undefined;
}
return (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
handleOpenSchedule(scheduleEvent);
};
};
const getSlotClickHandler = (event: Event) => {
if (!onSlotClick) {
return undefined;
@ -127,7 +152,6 @@ const Rotation: FC<RotationProps> = (props) => {
{events.map((event) => {
return (
<ScheduleSlot
scheduleId={scheduleId}
key={hash(event)}
event={event}
startMoment={startMoment}
@ -135,16 +159,17 @@ const Rotation: FC<RotationProps> = (props) => {
color={propsColor || getColor(event.shift?.pk)}
handleAddOverride={getAddOverrideClickHandler(event)}
handleAddShiftSwap={getAddShiftSwapClickHandler(event)}
handleOpenSchedule={getOpenScheduleClickHandler(event)}
onShiftSwapClick={onShiftSwapClick}
simplified={simplified}
filters={filters}
onClick={getSlotClickHandler(event)}
showScheduleNameAsSlotTitle={showScheduleNameAsSlotTitle}
/>
);
})}
</div>
) : (
<Empty />
<Empty text={emptyText} />
)
) : (
<HorizontalGroup align="center" justify="center">
@ -156,8 +181,12 @@ const Rotation: FC<RotationProps> = (props) => {
);
};
const Empty = () => {
return <div className={cx('empty')} />;
const Empty = ({ text }: { text: string }) => {
return (
<div className={cx('empty')}>
<Text type="secondary">{text}</Text>
</div>
);
};
export default Rotation;

View file

@ -86,7 +86,7 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
if (id === 'new') {
store.scheduleStore.updateShiftsSwapPreview(scheduleId, startMoment, {
id: 'new',
beneficiary: currentUserPk,
beneficiary: { pk: currentUserPk },
...shiftSwap,
});
}
@ -120,13 +120,7 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
onUpdate();
}, [id]);
useEffect(() => {
if (shiftSwap?.beneficiary && !store.userStore.items[shiftSwap.beneficiary]) {
store.userStore.updateItem(shiftSwap.beneficiary);
}
}, [shiftSwap?.beneficiary]);
const beneficiaryName = shiftSwap?.beneficiary && store.userStore.items[shiftSwap.beneficiary]?.name;
const beneficiaryName = shiftSwap?.beneficiary?.display_name;
const isNew = id === 'new';
const isPastDue = useMemo(() => shiftSwap && dayjs(shiftSwap.swap_start).isBefore(dayjs()), [shiftSwap]);
@ -159,7 +153,7 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
tooltip="Delete"
name="trash-alt"
onClick={handleDelete}
disabled={shiftSwap.beneficiary !== currentUserPk}
disabled={shiftSwap.beneficiary?.pk !== currentUserPk}
/>
</WithConfirm>
</WithPermissionControlTooltip>
@ -204,7 +198,7 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
<Field label="Swapped by">
{shiftSwap?.benefactor ? (
<UserItem
pk={shiftSwap?.benefactor}
pk={shiftSwap?.benefactor.pk}
shiftColor={SHIFT_SWAP_COLOR}
shiftStart={shiftSwap.swap_start}
shiftEnd={shiftSwap.swap_end}
@ -228,7 +222,9 @@ const ShiftSwapForm = (props: ShiftSwapFormProps) => {
<Button
variant="primary"
onClick={handleTake}
disabled={Boolean(isPastDue || shiftSwap?.benefactor || shiftSwap.beneficiary === currentUserPk)}
disabled={Boolean(
isPastDue || shiftSwap?.benefactor || shiftSwap.beneficiary?.pk === currentUserPk
)}
>
Accept
</Button>

View file

@ -167,7 +167,6 @@ class Rotations extends Component<RotationsProps, RotationsState> {
classNames={{ ...styles }}
>
<Rotation
scheduleId={scheduleId}
onClick={(shiftStart, shiftEnd) => {
this.onRotationClick(shiftId, shiftStart, shiftEnd);
}}
@ -206,7 +205,6 @@ class Rotations extends Component<RotationsProps, RotationsState> {
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
<div className={cx('rotations')}>
<Rotation
scheduleId={scheduleId}
onClick={(shiftStart, shiftEnd) => {
this.handleAddLayer(nextPriority, shiftStart, shiftEnd);
}}

View file

@ -33,7 +33,6 @@ interface ScheduleFinalProps extends WithStoreProps {
currentTimezone: Timezone;
scheduleId: Schedule['id'];
simplified?: boolean;
onClick: (shiftId: Shift['id']) => void;
onShowOverrideForm: (shiftId: 'new', shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => void;
onShowShiftSwapForm: (id: ShiftSwap['id'] | 'new', params?: Partial<ShiftSwap>) => void;
disabled?: boolean;
@ -41,16 +40,8 @@ interface ScheduleFinalProps extends WithStoreProps {
onSlotClick?: (event: Event) => void;
}
interface ScheduleOverridesState {
searchTerm: string;
}
@observer
class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState> {
state: ScheduleOverridesState = {
searchTerm: '',
};
class ScheduleFinal extends Component<ScheduleFinalProps> {
render() {
const { startMoment, currentTimezone, store, simplified, scheduleId, filters, onShowShiftSwapForm, onSlotClick } =
this.props;
@ -94,7 +85,6 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
key={index}
scheduleId={scheduleId}
events={events}
startMoment={startMoment}
currentTimezone={currentTimezone}
@ -111,12 +101,7 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
})
) : (
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
scheduleId={scheduleId}
events={[]}
startMoment={startMoment}
currentTimezone={currentTimezone}
/>
<Rotation events={[]} startMoment={startMoment} currentTimezone={currentTimezone} />
</CSSTransition>
)}
</TransitionGroup>
@ -126,8 +111,6 @@ class ScheduleFinal extends Component<ScheduleFinalProps, ScheduleOverridesState
);
}
onSearchTermChangeCallback = () => {};
handleShowOverrideForm = (shiftStart: dayjs.Dayjs, shiftEnd: dayjs.Dayjs) => {
const { onShowOverrideForm } = this.props;

View file

@ -155,7 +155,6 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
? shiftSwaps.map(({ isPreview, events }, index) => (
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
scheduleId={scheduleId}
events={events}
color={SHIFT_SWAP_COLOR}
startMoment={startMoment}
@ -178,7 +177,6 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
shifts.map(({ shiftId, isPreview, events }, index) => (
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
scheduleId={scheduleId}
events={events}
color={getOverrideColor(index)}
startMoment={startMoment}
@ -196,7 +194,6 @@ class ScheduleOverrides extends Component<ScheduleOverridesProps, ScheduleOverri
<Rotation
key={0}
events={[]}
scheduleId={scheduleId}
startMoment={startMoment}
currentTimezone={currentTimezone}
onClick={(shiftStart, shiftEnd) => {

View file

@ -0,0 +1,143 @@
import React, { Component } from 'react';
import { Badge, HorizontalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import dayjs from 'dayjs';
import { observer } from 'mobx-react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { CSSTransition, TransitionGroup } from 'react-transition-group';
import Avatar from 'components/Avatar/Avatar';
import Text from 'components/Text/Text';
import TimelineMarks from 'components/TimelineMarks/TimelineMarks';
import Rotation from 'containers/Rotation/Rotation';
import { getColorForSchedule, getPersonalShiftsFromStore } from 'models/schedule/schedule.helpers';
import { Shift, Event } from 'models/schedule/schedule.types';
import { Timezone } from 'models/timezone/timezone.types';
import { User } from 'models/user/user.types';
import { WithStoreProps } from 'state/types';
import { withMobXProviderContext } from 'state/withStore';
import { PLUGIN_ROOT } from 'utils/consts';
import { DEFAULT_TRANSITION_TIMEOUT } from './Rotations.config';
import styles from './Rotations.module.css';
const cx = cn.bind(styles);
interface SchedulePersonalProps extends WithStoreProps, RouteComponentProps {
startMoment: dayjs.Dayjs;
currentTimezone: Timezone;
userPk: User['pk'];
onSlotClick?: (event: Event) => void;
}
@observer
class SchedulePersonal extends Component<SchedulePersonalProps> {
componentDidMount() {
const { store, startMoment } = this.props;
store.scheduleStore.updatePersonalEvents(store.userStore.currentUserPk, startMoment);
}
componentDidUpdate(prevProps: Readonly<SchedulePersonalProps>): void {
const { store, startMoment } = this.props;
if (prevProps.startMoment !== this.props.startMoment) {
store.scheduleStore.updatePersonalEvents(store.userStore.currentUserPk, startMoment);
}
}
render() {
const { userPk, startMoment, currentTimezone, store, onSlotClick } = this.props;
const base = 7 * 24 * 60; // in minutes
const diff = dayjs().tz(currentTimezone).diff(startMoment, 'minutes');
const currentTimeX = diff / base;
const shifts = getPersonalShiftsFromStore(store, userPk, startMoment);
const currentTimeHidden = currentTimeX < 0 || currentTimeX > 1;
const getColor = (shiftId: Shift['id']) => {
const shift = store.scheduleStore.shifts[shiftId];
if (!shift) {
if (shiftId) {
store.scheduleStore.updateOncallShift(shiftId);
}
return;
}
return getColorForSchedule(shift.schedule);
};
const isOncall = store.scheduleStore.onCallNow[userPk];
const storeUser = store.userStore.items[userPk];
return (
<>
<div className={cx('root')}>
<div className={cx('header')}>
<div className={cx('title')}>
<HorizontalGroup>
<Text type="secondary">
On-call schedule <Avatar src={storeUser.avatar} size="small" /> {store.userStore.currentUser.name}
</Text>
{/* @ts-ignore */}
{isOncall ? <Badge text="On-call now" color="green" /> : <Badge text="Not on-call now" color="gray" />}
</HorizontalGroup>
</div>
</div>
<div className={cx('header-plus-content')}>
{!currentTimeHidden && <div className={cx('current-time')} style={{ left: `${currentTimeX * 100}%` }} />}
<TimelineMarks startMoment={startMoment} timezone={currentTimezone} />
<TransitionGroup className={cx('rotations')}>
{shifts && shifts.length ? (
shifts.map(({ events }, index) => {
return (
<CSSTransition key={index} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
simplified
key={index}
events={events}
startMoment={startMoment}
currentTimezone={currentTimezone}
getColor={getColor}
onSlotClick={onSlotClick}
handleOpenSchedule={this.openSchedule}
showScheduleNameAsSlotTitle
/>
</CSSTransition>
);
})
) : (
<CSSTransition key={0} timeout={DEFAULT_TRANSITION_TIMEOUT} classNames={{ ...styles }}>
<Rotation
events={[]}
startMoment={startMoment}
currentTimezone={currentTimezone}
emptyText="There are no schedules relevant to user"
/>
</CSSTransition>
)}
</TransitionGroup>
</div>
</div>
</>
);
}
openSchedule = (event: Event) => {
const { store, history } = this.props;
const shiftId = event.shift?.pk;
const shift = store.scheduleStore.shifts[shiftId];
history.push(`${PLUGIN_ROOT}/schedules/${shift.schedule}`);
};
}
export default withRouter(withMobXProviderContext(SchedulePersonal));

View file

@ -10,7 +10,7 @@ import { ScheduleFiltersType } from 'components/ScheduleFilters/ScheduleFilters.
import Text from 'components/Text/Text';
import WorkingHours from 'components/WorkingHours/WorkingHours';
import { getShiftName, SHIFT_SWAP_COLOR } from 'models/schedule/schedule.helpers';
import { Event, Schedule, ShiftSwap } from 'models/schedule/schedule.types';
import { Event, ShiftSwap } from 'models/schedule/schedule.types';
import { getTzOffsetString } from 'models/timezone/timezone.helpers';
import { Timezone } from 'models/timezone/timezone.types';
import { User } from 'models/user/user.types';
@ -22,16 +22,16 @@ import styles from './ScheduleSlot.module.css';
interface ScheduleSlotProps {
event: Event;
scheduleId: Schedule['id'];
startMoment: dayjs.Dayjs;
currentTimezone: Timezone;
handleAddOverride: (event: React.MouseEvent<HTMLDivElement>) => void;
handleAddShiftSwap: (event: React.MouseEvent<HTMLDivElement>) => void;
handleOpenSchedule: (event: React.MouseEvent<HTMLDivElement>) => void;
onShiftSwapClick: (id: ShiftSwap['id']) => void;
color?: string;
simplified?: boolean;
filters?: ScheduleFiltersType;
onClick: (event: React.MouseEvent<HTMLDivElement>) => void;
showScheduleNameAsSlotTitle?: boolean;
}
const cx = cn.bind(styles);
@ -39,15 +39,15 @@ const cx = cn.bind(styles);
const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
const {
event,
scheduleId,
currentTimezone,
color,
handleAddOverride,
handleAddShiftSwap,
handleOpenSchedule,
onShiftSwapClick,
simplified,
filters,
onClick,
showScheduleNameAsSlotTitle,
} = props;
const start = dayjs(event.start);
@ -63,14 +63,7 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
const renderEvent = (event): React.ReactElement | React.ReactElement[] => {
if (event.shiftSwapId) {
return (
<ShiftSwapEvent
currentMoment={currentMoment}
event={event}
simplified={simplified}
currentTimezone={currentTimezone}
/>
);
return <ShiftSwapEvent currentMoment={currentMoment} event={event} currentTimezone={currentTimezone} />;
}
if (event.is_gap) {
@ -95,17 +88,17 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
return (
<RegularEvent
event={event}
scheduleId={scheduleId}
handleAddOverride={handleAddOverride}
handleAddShiftSwap={handleAddShiftSwap}
handleOpenSchedule={handleOpenSchedule}
onShiftSwapClick={onShiftSwapClick}
filters={filters}
start={start}
duration={duration}
currentTimezone={currentTimezone}
simplified={simplified}
color={color}
currentMoment={currentMoment}
showScheduleNameAsSlotTitle={showScheduleNameAsSlotTitle}
/>
);
};
@ -122,39 +115,41 @@ export default ScheduleSlot;
interface ShiftSwapEventProps {
event: Event;
currentTimezone: Timezone;
simplified: boolean;
currentMoment: dayjs.Dayjs;
}
const ShiftSwapEvent = (props: ShiftSwapEventProps) => {
const { event, currentTimezone, simplified, currentMoment } = props;
const { event, currentTimezone, currentMoment } = props;
const store = useStore();
const shiftSwap = store.scheduleStore.shiftSwaps[event.shiftSwapId];
const beneficiary = shiftSwap?.beneficiary;
const benefactor = shiftSwap?.benefactor;
useEffect(() => {
if (shiftSwap?.beneficiary && !store.userStore.items[shiftSwap.beneficiary]) {
store.userStore.updateItem(shiftSwap.beneficiary);
if (shiftSwap?.beneficiary && !store.userStore.items[shiftSwap.beneficiary.pk]) {
store.userStore.updateItem(shiftSwap.beneficiary.pk);
}
}, [shiftSwap?.beneficiary]);
useEffect(() => {
if (shiftSwap?.benefactor && !store.userStore.items[shiftSwap.benefactor]) {
store.userStore.updateItem(shiftSwap.benefactor);
if (shiftSwap?.benefactor && !store.userStore.items[shiftSwap.benefactor.pk]) {
store.userStore.updateItem(shiftSwap.benefactor.pk);
}
}, [shiftSwap?.benefactor]);
const beneficiary = store.userStore.items[shiftSwap?.beneficiary];
const benefactor = store.userStore.items[shiftSwap?.benefactor];
const beneficiaryStoreUser = store.userStore.items[shiftSwap?.beneficiary?.pk];
const benefactorStoreUser = store.userStore.items[shiftSwap?.benefactor?.pk];
const scheduleSlotContent = (
<div className={cx('root', { 'root__type_shift-swap': true })}>
{shiftSwap && (
<HorizontalGroup spacing="xs">
{beneficiary && <Avatar size="xs" src={beneficiary.avatar} />}
{beneficiary && <Avatar size="xs" src={beneficiary.avatar_full} />}
{benefactor ? (
<Avatar size="xs" src={benefactor.avatar} />
<Avatar size="xs" src={benefactor.avatar_full} />
) : (
<div className={cx('no-user')}>
<Text size="xs" type="primary">
@ -177,12 +172,11 @@ const ShiftSwapEvent = (props: ShiftSwapEventProps) => {
content={
<ScheduleSlotDetails
isShiftSwap
beneficiaryName={beneficiary?.name}
user={benefactor || beneficiary}
benefactorName={benefactor?.name}
beneficiaryName={beneficiary?.display_name}
user={benefactorStoreUser || beneficiaryStoreUser}
benefactorName={benefactor?.display_name}
currentTimezone={currentTimezone}
event={event}
simplified={simplified}
color={SHIFT_SWAP_COLOR}
currentMoment={currentMoment}
/>
@ -195,33 +189,33 @@ const ShiftSwapEvent = (props: ShiftSwapEventProps) => {
interface RegularEventProps {
event: Event;
scheduleId: Schedule['id'];
currentTimezone: Timezone;
handleAddOverride: (event: React.MouseEvent<HTMLDivElement>) => void;
handleAddShiftSwap: (event: React.MouseEvent<HTMLDivElement>) => void;
handleOpenSchedule: (event: React.MouseEvent<HTMLDivElement>) => void;
onShiftSwapClick: (id: ShiftSwap['id']) => void;
simplified: boolean;
color?: string;
filters?: ScheduleFiltersType;
start: dayjs.Dayjs;
duration: number;
currentMoment: dayjs.Dayjs;
showScheduleNameAsSlotTitle: boolean;
}
const RegularEvent = (props: RegularEventProps) => {
const {
event,
scheduleId,
onShiftSwapClick,
filters,
color,
currentTimezone,
simplified,
start,
duration,
handleAddOverride,
handleAddShiftSwap,
handleOpenSchedule,
currentMoment,
showScheduleNameAsSlotTitle,
} = props;
const store = useStore();
@ -238,10 +232,6 @@ const RegularEvent = (props: RegularEventProps) => {
[onShiftSwapClick]
);
const onCallNow = store.scheduleStore.items[scheduleId]?.on_call_now;
const enableWebOverrides = store.scheduleStore.items[scheduleId]?.enable_web_overrides;
return (
<>
{users.map(({ display_name, pk: userPk, swap_request }) => {
@ -250,11 +240,7 @@ const RegularEvent = (props: RegularEventProps) => {
const isCurrentUserSlot = userPk === store.userStore.currentUserPk;
const inactive = filters && filters.users.length && !filters.users.includes(userPk);
const title = storeUser ? getTitle(storeUser) : display_name;
const isOncall = Boolean(
storeUser && onCallNow && onCallNow.some((onCallUser) => storeUser.pk === onCallUser.pk)
);
const userTitle = storeUser ? getTitle(storeUser) : display_name;
const isShiftSwap = Boolean(swap_request);
@ -281,7 +267,7 @@ const RegularEvent = (props: RegularEventProps) => {
/>
)}
<div className={cx('title')}>
{swap_request && !swap_request.user ? <Icon name="user-arrows" /> : title}
{swap_request && !swap_request.user ? <Icon name="user-arrows" /> : userTitle}
</div>
</div>
);
@ -296,30 +282,26 @@ const RegularEvent = (props: RegularEventProps) => {
key={userPk}
content={
<ScheduleSlotDetails
showScheduleNameAsSlotTitle={showScheduleNameAsSlotTitle}
isShiftSwap={isShiftSwap}
beneficiaryName={
isShiftSwap ? (swap_request.user ? swap_request.user.display_name : display_name) : undefined
}
benefactorName={isShiftSwap ? (swap_request.user ? display_name : undefined) : undefined}
user={storeUser}
isOncall={isOncall}
currentTimezone={currentTimezone}
event={event}
handleAddOverride={
!enableWebOverrides ||
simplified ||
event.is_override ||
isShiftSwap ||
currentMoment.isAfter(dayjs(event.end))
!handleAddOverride || event.is_override || isShiftSwap || currentMoment.isAfter(dayjs(event.end))
? undefined
: handleAddOverride
}
handleAddShiftSwap={
simplified || isShiftSwap || !isCurrentUserSlot || currentMoment.isAfter(dayjs(event.start))
!handleAddShiftSwap || isShiftSwap || !isCurrentUserSlot || currentMoment.isAfter(dayjs(event.start))
? undefined
: handleAddShiftSwap
}
simplified={simplified}
handleOpenSchedule={handleOpenSchedule}
color={backgroundColor}
currentMoment={currentMoment}
/>
@ -340,12 +322,13 @@ interface ScheduleSlotDetailsProps {
event: Event;
handleAddOverride?: (event: React.SyntheticEvent) => void;
handleAddShiftSwap?: (event: React.SyntheticEvent) => void;
simplified?: boolean;
handleOpenSchedule?: (event: React.SyntheticEvent) => void;
color: string;
isShiftSwap?: boolean;
beneficiaryName?: string;
benefactorName?: string;
currentMoment: dayjs.Dayjs;
showScheduleNameAsSlotTitle?: boolean;
}
const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
@ -355,17 +338,40 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
event,
handleAddOverride,
handleAddShiftSwap,
handleOpenSchedule,
color,
isShiftSwap,
beneficiaryName,
benefactorName,
currentMoment,
showScheduleNameAsSlotTitle,
} = props;
const store = useStore();
const { scheduleStore } = store;
const { scheduleStore } = useStore();
const shift = scheduleStore.shifts[event.shift?.pk];
const shiftId = event.shift?.pk;
const shift = scheduleStore.shifts[shiftId];
const schedule = scheduleStore.items[shift?.schedule];
const enableWebOverrides = schedule?.enable_web_overrides;
useEffect(() => {
if (shiftId && !scheduleStore.shifts[shiftId]) {
scheduleStore.updateOncallShift(shiftId);
}
}, [shiftId]);
useEffect(() => {
if (shift && !scheduleStore.items[shift.schedule]) {
scheduleStore.loadItem(shift.schedule);
}
}, [shift]);
const title = isShiftSwap ? 'Shift swap' : showScheduleNameAsSlotTitle ? schedule?.name : getShiftName(shift);
// const onCallNow = schedule?.on_call_now;
// const isOncall = Boolean(storeUser && onCallNow && onCallNow.some((onCallUser) => storeUser.pk === onCallUser.pk));
return (
<div className={cx('details')}>
@ -375,7 +381,7 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
<div className={cx('badge')} style={{ backgroundColor: color }} />
</div>
<Text type="primary" maxWidth="222px">
{isShiftSwap ? 'Shift swap' : getShiftName(shift)}
{title}
</Text>
</HorizontalGroup>
<HorizontalGroup align="flex-start">
@ -445,11 +451,16 @@ const ScheduleSlotDetails = (props: ScheduleSlotDetailsProps) => {
Request shift swap
</Button>
)}
{handleAddOverride && (
{handleAddOverride && enableWebOverrides && (
<Button size="sm" variant="secondary" onClick={handleAddOverride}>
+ Override
</Button>
)}
{handleOpenSchedule && (
<Button size="sm" variant="secondary" onClick={handleOpenSchedule}>
Open schedule
</Button>
)}
</HorizontalGroup>
</VerticalGroup>
</div>

View file

@ -312,9 +312,11 @@ export class AlertGroupStore extends BaseStore {
}
@action
getAlert(pk: Alert['pk']) {
return makeRequest(`${this.path}${pk}`, {}).then((alert: Alert) => {
async getAlert(pk: Alert['pk']) {
return await makeRequest(`${this.path}${pk}`, {}).then((alert: Alert) => {
this.alerts.set(pk, alert);
return alert;
});
}

View file

@ -4,7 +4,7 @@ import BaseStore from 'models/base_store';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { OutgoingWebhook } from './outgoing_webhook.types';
import { OutgoingWebhook, OutgoingWebhookPreset } from './outgoing_webhook.types';
export class OutgoingWebhookStore extends BaseStore {
@observable.shallow
@ -13,6 +13,9 @@ export class OutgoingWebhookStore extends BaseStore {
@observable.shallow
searchResult: { [key: string]: Array<OutgoingWebhook['id']> } = {};
@observable.shallow
outgoingWebhookPresets: OutgoingWebhookPreset[] = [];
constructor(rootStore: RootStore) {
super(rootStore);
@ -97,4 +100,10 @@ export class OutgoingWebhookStore extends BaseStore {
data: { template_name, template_body, payload },
});
}
@action
async updateOutgoingWebhookPresets() {
const response = await makeRequest(`/webhooks/preset_options/`, {});
this.outgoingWebhookPresets = response;
}
}

View file

@ -18,6 +18,7 @@ export interface OutgoingWebhook {
last_response_log?: OutgoingWebhookResponse;
is_webhook_enabled: boolean;
is_legacy: boolean;
preset: string;
}
export interface OutgoingWebhookResponse {
@ -30,3 +31,11 @@ export interface OutgoingWebhookResponse {
content: string;
event_data: string;
}
export interface OutgoingWebhookPreset {
id: string;
name: string;
description: string;
logo: string;
controlled_fields: string[];
}

View file

@ -1,5 +1,6 @@
import dayjs from 'dayjs';
import { User } from 'models/user/user.types';
import { RootStore } from 'state';
import { Event, Layer, Schedule, ScheduleType, Shift, ShiftEvents, ShiftSwap } from './schedule.types';
@ -83,6 +84,14 @@ export const splitToShiftsAndFillGaps = (events: Event[]) => {
return shifts;
};
export const getPersonalShiftsFromStore = (
store: RootStore,
userPk: User['pk'],
startMoment: dayjs.Dayjs
): ShiftEvents[] => {
return store.scheduleStore.personalEvents[userPk]?.[getFromString(startMoment)] as any;
};
export const getShiftsFromStore = (
store: RootStore,
scheduleId: Schedule['id'],
@ -355,6 +364,25 @@ export const SHIFT_SWAP_COLOR = '#C69B06';
const COLORS = [L1_COLORS, L2_COLORS, L3_COLORS];
const scheduleToColor = {};
export const getColorForSchedule = (scheduleId: Schedule['id']) => {
if (scheduleToColor[scheduleId]) {
return scheduleToColor[scheduleId];
}
const colors = [...L1_COLORS, ...L2_COLORS, ...L3_COLORS];
const index = Object.keys(scheduleToColor).length;
const normalizedIndex = index % colors.length;
const color = colors[normalizedIndex];
scheduleToColor[scheduleId] = color;
return color;
};
export const getColor = (layerIndex: number, rotationIndex: number) => {
const normalizedLayerIndex = layerIndex % COLORS.length;
const normalizedRotationIndex = rotationIndex % COLORS[normalizedLayerIndex]?.length;

View file

@ -4,6 +4,7 @@ import { action, observable } from 'mobx';
import { RemoteFiltersType } from 'containers/RemoteFilters/RemoteFilters.types';
import BaseStore from 'models/base_store';
import { EscalationChain } from 'models/escalation_chain/escalation_chain.types';
import { User } from 'models/user/user.types';
import { makeRequest } from 'network';
import { RootStore } from 'state';
import { SelectOption } from 'state/types';
@ -41,6 +42,8 @@ export class ScheduleStore extends BaseStore {
@observable.shallow
shifts: { [id: string]: Shift } = {};
shiftsCurrentlyUpdating = {};
@observable.shallow
relatedEscalationChains: { [id: string]: EscalationChain[] } = {};
@ -69,6 +72,18 @@ export class ScheduleStore extends BaseStore {
};
} = {};
@observable.shallow
personalEvents: {
[userPk: string]: {
[startMoment: string]: ShiftEvents[];
};
} = {};
@observable.shallow
onCallNow: {
[userPk: string]: boolean;
} = {};
@observable
finalPreview?: { [fromString: string]: Array<{ shiftId: Shift['id']; events: Event[] }> };
@ -385,6 +400,12 @@ export class ScheduleStore extends BaseStore {
@action
async updateOncallShift(shiftId: Shift['id']) {
if (this.shiftsCurrentlyUpdating[shiftId]) {
return;
}
this.shiftsCurrentlyUpdating[shiftId] = true;
const response = await makeRequest(`/oncall_shifts/${shiftId}`, {});
this.shifts = {
@ -392,6 +413,8 @@ export class ScheduleStore extends BaseStore {
[shiftId]: response,
};
delete this.shiftsCurrentlyUpdating[shiftId];
return response;
}
@ -467,7 +490,7 @@ export class ScheduleStore extends BaseStore {
}
async loadShiftSwap(id: ShiftSwap['id']) {
const result = await makeRequest(`/shift_swaps/${id}`, {});
const result = await makeRequest(`/shift_swaps/${id}`, { params: { expand_users: true } });
this.shiftSwaps = { ...this.shiftSwaps, [id]: result };
@ -511,4 +534,37 @@ export class ScheduleStore extends BaseStore {
},
};
}
async updatePersonalEvents(userPk: User['pk'], startMoment: dayjs.Dayjs, days = 9) {
const fromString = getFromString(startMoment);
const dayBefore = startMoment.subtract(1, 'day');
const { is_oncall, schedules } = await makeRequest(`/schedules/current_user_events/`, {
method: 'GET',
params: {
date: getFromString(dayBefore),
days,
},
});
const shiftEventsList = schedules.reduce((acc, schedule) => {
return [...acc, ...splitToShiftsAndFillGaps(schedule.events)];
}, []);
const shiftEventsListFlattened = flattenShiftEvents(shiftEventsList);
this.personalEvents = {
...this.personalEvents,
[userPk]: {
...this.personalEvents[userPk],
[fromString]: shiftEventsListFlattened,
},
};
this.onCallNow = {
...this.onCallNow,
[userPk]: is_oncall,
};
}
}

View file

@ -139,9 +139,9 @@ export interface ShiftSwap {
schedule: Schedule['id'];
swap_start: string;
swap_end: string;
beneficiary: User['pk'];
beneficiary: Partial<User>;
status: 'open' | 'taken' | 'past_due';
benefactor: User['pk'];
benefactor: Partial<User>;
description: string;
}

View file

@ -10,7 +10,9 @@ export interface User {
email: string;
phone: string;
avatar: string;
avatar_full: string;
name: string;
display_name: string;
company: string;
hide_phone_number: boolean;
role_in_company: string;

View file

@ -64,7 +64,10 @@ import PagedUsers from './parts/PagedUsers';
const cx = cn.bind(styles);
const INTEGRATION_NAME_LENGTH_LIMIT = 30;
interface IncidentPageProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
interface IncidentPageProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {
pageTitle: string;
setPageTitle: (value: string) => void;
}
interface IncidentPageState extends PageBaseState {
showIntegrationSettings?: boolean;
@ -89,6 +92,12 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
store.alertGroupStore.updateSilenceOptions();
}
componentWillUnmount(): void {
const { setPageTitle } = this.props;
setPageTitle(undefined);
}
componentDidUpdate(prevProps: IncidentPageProps) {
if (this.props.match.params.id !== prevProps.match.params.id) {
this.update();
@ -103,10 +112,14 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
match: {
params: { id },
},
setPageTitle,
} = this.props;
store.alertGroupStore
.getAlert(id)
.then((alertGroup) => {
setPageTitle(`#${alertGroup.inside_organization_number} ${alertGroup.render_for_web.title}`);
})
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
};
@ -238,6 +251,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
match: {
params: { id },
},
pageTitle,
} = this.props;
const { alerts } = store.alertGroupStore;
@ -261,7 +275,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
{/* @ts-ignore*/}
<HorizontalGroup align="baseline">
<Text.Title level={3} data-testid="incident-title">
#{incident.inside_organization_number} {incident.render_for_web.title}
{pageTitle}
</Text.Title>
{incident.root_alert_group && (
<Text type="secondary">

View file

@ -17,7 +17,7 @@ export type PageDefinition = {
action?: UserAction;
hideTitle: boolean; // dont't automatically render title above page content
getPageNav(): { text: string; description: string };
getPageNav: (pageTitle: string) => NavModelItem;
};
function getPath(name = '') {
@ -34,6 +34,20 @@ export const pages: { [id: string]: PageDefinition } = [
path: getPath('alert-groups'),
action: UserActions.AlertGroupsRead,
},
{
icon: 'bell',
id: 'alert-group',
text: '',
showOrgSwitcher: true,
getParentItem: (pageTitle: string) => ({
text: pageTitle,
url: `${PLUGIN_ROOT}/alert-groups`,
}),
hideFromBreadcrumbs: true,
hideFromTabs: true,
path: getPath('alert-group/:id?'),
action: UserActions.AlertGroupsRead,
},
{
icon: 'users-alt',
id: 'users',
@ -63,6 +77,7 @@ export const pages: { [id: string]: PageDefinition } = [
icon: 'calendar-alt',
id: 'schedules',
text: 'Schedules',
hideTitle: true,
hideFromBreadcrumbs: true,
path: getPath('schedules'),
action: UserActions.SchedulesRead,
@ -71,10 +86,10 @@ export const pages: { [id: string]: PageDefinition } = [
icon: 'calendar-alt',
id: 'schedule',
text: '',
parentItem: {
text: 'Schedule',
getParentItem: (pageTitle: string) => ({
text: pageTitle,
url: `${PLUGIN_ROOT}/schedules`,
},
}),
hideFromBreadcrumbs: true,
hideFromTabs: true,
path: getPath('schedule/:id?'),
@ -138,10 +153,10 @@ export const pages: { [id: string]: PageDefinition } = [
if (!current.action || (current.action && isUserActionAllowed(current.action))) {
prev[current.id] = {
...current,
getPageNav: () =>
getPageNav: (pageTitle: string) =>
({
text: isTopNavbar() ? '' : current.text,
parentItem: current.parentItem,
parentItem: current.getParentItem ? current.getParentItem(pageTitle) : undefined,
hideFromBreadcrumbs: current.hideFromBreadcrumbs,
hideFromTabs: current.hideFromTabs,
} as NavModelItem),

View file

@ -186,7 +186,7 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
>
<WithPermissionControlTooltip userAction={UserActions.OutgoingWebhooksWrite}>
<Button variant="primary" icon="plus">
Create
New Outgoing Webhook
</Button>
</WithPermissionControlTooltip>
</PluginLink>

View file

@ -39,7 +39,10 @@ import styles from './Schedule.module.css';
const cx = cn.bind(styles);
interface SchedulePageProps extends PageProps, WithStoreProps, RouteComponentProps<{ id: string }> {}
interface SchedulePageProps extends PageProps, WithStoreProps, RouteComponentProps<{ id: string }> {
pageTitle: string;
setPageTitle: (value: string) => void;
}
interface SchedulePageState extends PageBaseState {
startMoment: dayjs.Dayjs;
@ -100,15 +103,18 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
}
componentWillUnmount() {
const { store } = this.props;
const { store, setPageTitle } = this.props;
store.scheduleStore.clearPreview();
setPageTitle(undefined);
}
render() {
const {
store,
query,
pageTitle,
match: {
params: { id: scheduleId },
},
@ -181,7 +187,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
level={2}
onTextChange={this.handleNameChange}
>
{schedule?.name}
{pageTitle}
</Text.Title>
{schedule && <ScheduleQuality schedule={schedule} lastUpdated={this.state.lastUpdated} />}
</div>
@ -359,10 +365,14 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
match: {
params: { id: scheduleId },
},
setPageTitle,
} = this.props;
const { scheduleStore } = store;
return scheduleStore.loadItem(scheduleId);
return scheduleStore.loadItem(scheduleId).then((schedule) => {
setPageTitle(schedule?.name);
});
};
handleShowForm = async (shiftId: Shift['id'] | 'new') => {
@ -397,13 +407,17 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
match: {
params: { id: scheduleId },
},
setPageTitle,
} = this.props;
const schedule = store.scheduleStore.items[scheduleId];
store.scheduleStore
.update(scheduleId, { type: schedule.type, name: value })
.then(() => store.scheduleStore.loadItem(scheduleId));
.then(() => store.scheduleStore.loadItem(scheduleId))
.then((schedule) => {
setPageTitle(schedule?.name);
});
};
updateEvents = () => {
@ -412,6 +426,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
match: {
params: { id: scheduleId },
},
setPageTitle,
} = this.props;
const { startMoment } = this.state;
@ -423,6 +438,9 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
store.scheduleStore
.loadItem(scheduleId) // to refresh current oncall users
.then((schedule) => {
setPageTitle(schedule?.name);
})
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
store.scheduleStore.updateRelatedUsers(scheduleId); // to refresh related users

View file

@ -3,6 +3,14 @@
margin: 20px 0;
}
.schedule-personal {
position: relative;
margin: 20px 0;
--rotations-border: var(--border-weak);
--rotations-background: var(--background-secondary);
}
.title {
margin-bottom: var(--title-marginBottom);
}
@ -18,6 +26,7 @@
row-gap: 4px;
column-gap: 8px;
width: 100%;
margin-bottom: 20px;
}
.schedules__actions {
@ -25,7 +34,6 @@
justify-content: flex-end;
flex-grow: 1;
gap: 8px;
padding-top: 19px;
}
.schedules__user-on-call {

View file

@ -21,6 +21,7 @@ import WithConfirm from 'components/WithConfirm/WithConfirm';
import RemoteFilters from 'containers/RemoteFilters/RemoteFilters';
import { RemoteFiltersType } from 'containers/RemoteFilters/RemoteFilters.types';
import ScheduleFinal from 'containers/Rotations/ScheduleFinal';
import SchedulePersonal from 'containers/Rotations/SchedulePersonal';
import ScheduleForm from 'containers/ScheduleForm/ScheduleForm';
import TeamName from 'containers/TeamName/TeamName';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
@ -68,31 +69,19 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
};
}
/* async componentDidMount() {
componentDidMount(): void {
const {
store,
query: { p },
store: { userStore },
} = this.props;
const { filters, page } = this.state;
userStore.updateItems();
}
await store.scheduleStore.updateItems(filters, page, () => filters === this.state.filters);
this.setState({ page: p ? Number(p) : 1 }, this.updateSchedules);
} */
/* updateSchedules = async () => {
const { store } = this.props;
const { filters, page } = this.state;
await store.scheduleStore.updateItems(filters, page);
};
*/
render() {
const { store, query } = this.props;
const { grafanaTeamStore } = store;
const { showNewScheduleSelector, expandedRowKeys, scheduleIdToEdit, page } = this.state;
const { showNewScheduleSelector, expandedRowKeys, scheduleIdToEdit, page, startMoment } = this.state;
const { results, count } = store.scheduleStore.getSearchResult();
@ -149,14 +138,9 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
return (
<>
<div className={cx('root')}>
<VerticalGroup>
<div className={cx('schedules__filters-container')}>
<RemoteFilters
query={query}
page={PAGE.Schedules}
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleSchedulesFiltersChange}
/>
<div className={cx('title')}>
<HorizontalGroup justify="space-between">
<Text.Title level={3}>Schedules</Text.Title>
<div className={cx('schedules__actions')}>
{users && (
<UserTimezoneSelect
@ -171,22 +155,40 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
</Button>
</WithPermissionControlTooltip>
</div>
</div>
<Table
columns={columns}
data={results}
loading={!results}
pagination={{ page, total: Math.ceil((count || 0) / ITEMS_PER_PAGE), onChange: this.handlePageChange }}
rowKey="id"
expandable={{
expandedRowKeys: expandedRowKeys,
onExpand: this.handleExpandRow,
expandedRowRender: this.renderSchedule,
expandRowByClick: true,
</HorizontalGroup>
</div>
<div className={cx('schedule', 'schedule-personal')}>
<SchedulePersonal
userPk={store.userStore.currentUserPk}
currentTimezone={store.currentTimezone}
startMoment={startMoment}
onSlotClick={(...rest) => {
console.log(rest);
}}
emptyText={this.renderNotFound()}
/>
</VerticalGroup>
</div>
<div className={cx('schedules__filters-container')}>
<RemoteFilters
query={query}
page={PAGE.Schedules}
grafanaTeamStore={store.grafanaTeamStore}
onChange={this.handleSchedulesFiltersChange}
/>
</div>
<Table
columns={columns}
data={results}
loading={!results}
pagination={{ page, total: Math.ceil((count || 0) / ITEMS_PER_PAGE), onChange: this.handlePageChange }}
rowKey="id"
expandable={{
expandedRowKeys: expandedRowKeys,
onExpand: this.handleExpandRow,
expandedRowRender: this.renderSchedule,
expandRowByClick: true,
}}
emptyText={this.renderNotFound()}
/>
</div>
{showNewScheduleSelector && (
<NewScheduleSelector
@ -274,7 +276,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
scheduleId={data.id}
currentTimezone={store.currentTimezone}
startMoment={startMoment}
onClick={this.getScheduleClickHandler(data.id)}
onSlotClick={this.getScheduleClickHandler(data.id)}
/>
</div>
</div>
@ -451,7 +453,9 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
update = () => {
const { store } = this.props;
const { page } = this.state;
const { page, startMoment } = this.state;
store.scheduleStore.updatePersonalEvents(store.userStore.currentUserPk, startMoment);
// For removal we need to check if count is 1
// which means we should change the page to the previous one

View file

@ -38,6 +38,7 @@ import Users from 'pages/users/Users';
import { rootStore } from 'state';
import { useStore } from 'state/useStore';
import { isUserActionAllowed } from 'utils/authorization';
import { DEFAULT_PAGE } from 'utils/consts';
dayjs.extend(utc);
dayjs.extend(timezone);
@ -72,6 +73,8 @@ export const Root = observer((props: AppRootProps) => {
const [basicDataLoaded, setBasicDataLoaded] = useState(false);
const [pageTitle, setPageTitle] = useState('');
useEffect(() => {
runQueuedUpdateData(0);
}, []);
@ -103,8 +106,12 @@ export const Root = observer((props: AppRootProps) => {
const userHasAccess = pagePermissionAction ? isUserActionAllowed(pagePermissionAction) : true;
const query = getQueryParams();
const getPageNav = () => {
return (pages[page] || pages[DEFAULT_PAGE]).getPageNav(pageTitle);
};
return (
<DefaultPageLayout {...props} page={page}>
<DefaultPageLayout {...props} page={page} pageNav={getPageNav()}>
{!isTopNavbar() && (
<>
<Header />
@ -128,7 +135,7 @@ export const Root = observer((props: AppRootProps) => {
<Incidents query={query} />
</Route>
<Route path={getRoutesForPage('alert-group')} exact>
<Incident query={query} />
<Incident query={query} pageTitle={pageTitle} setPageTitle={setPageTitle} />
</Route>
<Route path={getRoutesForPage('users')} exact>
<Users query={query} />
@ -146,7 +153,7 @@ export const Root = observer((props: AppRootProps) => {
<Schedules query={query} />
</Route>
<Route path={getRoutesForPage('schedule')} exact>
<Schedule query={query} />
<Schedule query={query} pageTitle={pageTitle} setPageTitle={setPageTitle} />
</Route>
<Route path={getRoutesForPage('outgoing_webhooks')} exact>
<OutgoingWebhooks query={query} />

View file

@ -130,6 +130,7 @@ export class RootBaseStore {
this.userStore.updateNotificationPolicyOptions(),
this.userStore.updateNotifyByOptions(),
this.alertReceiveChannelStore.updateAlertReceiveChannelOptions(),
this.outgoingWebhookStore.updateOutgoingWebhookPresets(),
this.escalationPolicyStore.updateWebEscalationPolicyOptions(),
this.escalationPolicyStore.updateEscalationPolicyOptions(),
this.escalationPolicyStore.updateNumMinutesInWindowOptions(),