commit
65bca4a792
67 changed files with 1887 additions and 458 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
12
docs/docs.mk
12
docs/docs.mk
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
161
engine/apps/api/tests/test_webhook_presets.py
Normal file
161
engine/apps/api/tests/test_webhook_presets.py
Normal 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
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
23
engine/apps/webhooks/migrations/0011_auto_20230920_1813.py
Normal file
23
engine/apps/webhooks/migrations/0011_auto_20230920_1813.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
0
engine/apps/webhooks/presets/__init__.py
Normal file
0
engine/apps/webhooks/presets/__init__.py
Normal file
19
engine/apps/webhooks/presets/advanced.py
Normal file
19
engine/apps/webhooks/presets/advanced.py
Normal 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
|
||||
36
engine/apps/webhooks/presets/preset.py
Normal file
36
engine/apps/webhooks/presets/preset.py
Normal 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
|
||||
30
engine/apps/webhooks/presets/preset_options.py
Normal file
30
engine/apps/webhooks/presets/preset_options.py
Normal 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)
|
||||
32
engine/apps/webhooks/presets/simple.py
Normal file
32
engine/apps/webhooks/presets/simple.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
160
engine/apps/webhooks/tests/test_webhook_presets.py
Normal file
160
engine/apps/webhooks/tests/test_webhook_presets.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import { ReactElement } from 'react';
|
||||
|
||||
export const commonWebhookPresetIconsConfig: { [id: string]: () => ReactElement } = {};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { ReactElement } from 'react';
|
||||
|
||||
import { commonWebhookPresetIconsConfig } from './CommonWebhookPresetIcons.config';
|
||||
|
||||
export const webhookPresetIcons: { [id: string]: () => ReactElement } = commonWebhookPresetIconsConfig;
|
||||
|
|
@ -56,6 +56,7 @@
|
|||
.empty {
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
|
||||
/* background: #5f505633;
|
||||
border: 1px dashed #5c474d;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
143
grafana-plugin/src/containers/Rotations/SchedulePersonal.tsx
Normal file
143
grafana-plugin/src/containers/Rotations/SchedulePersonal.tsx
Normal 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));
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue