Merge dev
This commit is contained in:
commit
fc97a4e3de
45 changed files with 2598 additions and 841 deletions
|
|
@ -1,5 +1,11 @@
|
|||
# Change Log
|
||||
|
||||
## v1.0.5 (2022-07-12)
|
||||
|
||||
- Manual Incidents enabled for teams
|
||||
- Fix phone notifications for OSS
|
||||
- Public API improvements
|
||||
|
||||
## 1.0.4 (2022-06-28)
|
||||
- Allow Telegram DMs without channel connection.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class AlertWebRenderer(AlertBaseRenderer):
|
|||
"title": str_or_backup(templated_alert.title, "Alert"),
|
||||
"message": str_or_backup(templated_alert.message, ""),
|
||||
"image_url": str_or_backup(templated_alert.image_url, None),
|
||||
"source_link": str_or_backup(templated_alert.image_url, None),
|
||||
"source_link": str_or_backup(templated_alert.source_link, None),
|
||||
}
|
||||
return rendered_alert
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from django.apps import apps
|
||||
|
||||
from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater
|
||||
|
||||
|
||||
|
|
@ -12,3 +14,55 @@ class AlertSlackTemplater(AlertTemplater):
|
|||
if templated_alert.title:
|
||||
templated_alert.title = templated_alert.title.replace("\n", "").replace("\r", "")
|
||||
return templated_alert
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Overriden render method to modify payload of manual integration alerts
|
||||
"""
|
||||
self._modify_payload_for_manual_integration_if_needed()
|
||||
return super().render()
|
||||
|
||||
def _modify_payload_for_manual_integration_if_needed(self):
|
||||
"""
|
||||
Modifies payload of alerts made from manual incident integration.
|
||||
It is needed to simplify templates.
|
||||
"""
|
||||
payload = self.alert.raw_request_data
|
||||
# First check if payload look like payload from manual incident integration and was not modified before.
|
||||
if "view" in payload and "private_metadata" in payload.get("view", {}) and "oncall" not in payload:
|
||||
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
|
||||
# If so - check it with db query.
|
||||
if self.alert.group.channel.integration == AlertReceiveChannel.INTEGRATION_MANUAL:
|
||||
metadata = payload.get("view", {}).get("private_metadata", {})
|
||||
payload["oncall"] = {}
|
||||
if "message" in metadata:
|
||||
# If alert was made from message
|
||||
domain = payload.get("team", {}).get("domain", "unknown")
|
||||
channel_id = metadata.get("channel_id", "unknown")
|
||||
message = metadata.get("message", {})
|
||||
message_ts = message.get("ts", "unknown")
|
||||
message_text = message.get("text", "unknown")
|
||||
payload["oncall"]["permalink"] = f"https://{domain}.slack.com/archives/{channel_id}/p{message_ts}"
|
||||
payload["oncall"]["author_username"] = metadata.get("author_username", "Unknown")
|
||||
payload["oncall"]["title"] = "Message from @" + payload["oncall"]["author_username"]
|
||||
payload["oncall"]["message"] = message_text
|
||||
else:
|
||||
# If alert was made via slash command
|
||||
message_text = (
|
||||
payload.get("view", {})
|
||||
.get("state", {})
|
||||
.get("values", {})
|
||||
.get("MESSAGE_INPUT", {})
|
||||
.get("FinishCreateIncidentViewStep", {})
|
||||
.get("value", "unknown")
|
||||
)
|
||||
payload["oncall"]["permalink"] = None
|
||||
payload["oncall"]["title"] = self.alert.title
|
||||
payload["oncall"]["message"] = message_text
|
||||
created_by = self.alert.integration_unique_data.get("created_by", None)
|
||||
username = payload.get("user", {}).get("name", None)
|
||||
author_username = created_by or username or "unknown"
|
||||
payload["oncall"]["author_username"] = author_username
|
||||
|
||||
self.alert.raw_request_data = payload
|
||||
self.alert.save(update_fields=["raw_request_data"])
|
||||
|
|
|
|||
|
|
@ -32,5 +32,5 @@ class AlertWebTemplater(AlertTemplater):
|
|||
|
||||
def _slack_format_for_web(self, data):
|
||||
sf = self.slack_formatter
|
||||
sf.hyperlink_mention_format = "[title](url)"
|
||||
sf.hyperlink_mention_format = "[{title}]({url})"
|
||||
return sf.format(data)
|
||||
|
|
|
|||
|
|
@ -449,7 +449,9 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject):
|
|||
def get_or_create_manual_integration(cls, defaults, **kwargs):
|
||||
try:
|
||||
alert_receive_channel = cls.objects.get(
|
||||
organization=kwargs["organization"], integration=kwargs["integration"]
|
||||
organization=kwargs["organization"],
|
||||
integration=kwargs["integration"],
|
||||
team=kwargs["team"],
|
||||
)
|
||||
except cls.DoesNotExist:
|
||||
kwargs.update(defaults)
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se
|
|||
def validate_notification_backends(self, notification_backends):
|
||||
# NOTE: updates the whole field, handling dict updates per backend
|
||||
if notification_backends is not None:
|
||||
organization = self.context["request"].auth.organization
|
||||
if not isinstance(notification_backends, dict):
|
||||
raise serializers.ValidationError(["Invalid messaging backend data"])
|
||||
current = self.instance.notification_backends or {}
|
||||
|
|
@ -101,7 +102,7 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, EagerLoadingMixin, se
|
|||
if backend is None:
|
||||
raise serializers.ValidationError(["Invalid messaging backend"])
|
||||
updated_data = backend.validate_channel_filter_data(
|
||||
self.instance,
|
||||
organization,
|
||||
notification_backends[backend_id],
|
||||
)
|
||||
# update existing backend data
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ from apps.api.serializers.schedule_ical import (
|
|||
ScheduleICalSerializer,
|
||||
ScheduleICalUpdateSerializer,
|
||||
)
|
||||
from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal
|
||||
from apps.api.serializers.schedule_web import ScheduleWebCreateSerializer, ScheduleWebSerializer
|
||||
from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb
|
||||
from common.api_helpers.mixins import EagerLoadingMixin
|
||||
|
||||
|
||||
|
|
@ -18,9 +19,10 @@ class PolymorphicScheduleSerializer(EagerLoadingMixin, PolymorphicSerializer):
|
|||
model_serializer_mapping = {
|
||||
OnCallScheduleICal: ScheduleICalSerializer,
|
||||
OnCallScheduleCalendar: ScheduleCalendarSerializer,
|
||||
OnCallScheduleWeb: ScheduleWebSerializer,
|
||||
}
|
||||
|
||||
SCHEDULE_CLASS_TO_TYPE = {OnCallScheduleCalendar: 0, OnCallScheduleICal: 1}
|
||||
SCHEDULE_CLASS_TO_TYPE = {OnCallScheduleCalendar: 0, OnCallScheduleICal: 1, OnCallScheduleWeb: 2}
|
||||
|
||||
def to_resource_type(self, model_or_instance):
|
||||
return self.SCHEDULE_CLASS_TO_TYPE.get(model_or_instance._meta.model)
|
||||
|
|
@ -31,6 +33,7 @@ class PolymorphicScheduleCreateSerializer(PolymorphicScheduleSerializer):
|
|||
model_serializer_mapping = {
|
||||
OnCallScheduleICal: ScheduleICalCreateSerializer,
|
||||
OnCallScheduleCalendar: ScheduleCalendarCreateSerializer,
|
||||
OnCallScheduleWeb: ScheduleWebCreateSerializer,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -39,4 +42,5 @@ class PolymorphicScheduleUpdateSerializer(PolymorphicScheduleSerializer):
|
|||
OnCallScheduleICal: ScheduleICalUpdateSerializer,
|
||||
# There is no difference between create and Update serializers for ScheduleCalendar
|
||||
OnCallScheduleCalendar: ScheduleCalendarCreateSerializer,
|
||||
OnCallScheduleWeb: ScheduleWebCreateSerializer,
|
||||
}
|
||||
|
|
|
|||
46
engine/apps/api/serializers/schedule_web.py
Normal file
46
engine/apps/api/serializers/schedule_web.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from apps.api.serializers.schedule_base import ScheduleBaseSerializer
|
||||
from apps.schedules.models import OnCallScheduleWeb
|
||||
from apps.schedules.tasks import schedule_notify_about_empty_shifts_in_schedule, schedule_notify_about_gaps_in_schedule
|
||||
from apps.slack.models import SlackChannel, SlackUserGroup
|
||||
from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField
|
||||
|
||||
|
||||
class ScheduleWebSerializer(ScheduleBaseSerializer):
|
||||
time_zone = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = OnCallScheduleWeb
|
||||
fields = [*ScheduleBaseSerializer.Meta.fields, "slack_channel", "time_zone"]
|
||||
|
||||
|
||||
class ScheduleWebCreateSerializer(ScheduleWebSerializer):
|
||||
slack_channel_id = OrganizationFilteredPrimaryKeyRelatedField(
|
||||
filter_field="slack_team_identity__organizations",
|
||||
queryset=SlackChannel.objects,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
user_group = OrganizationFilteredPrimaryKeyRelatedField(
|
||||
filter_field="slack_team_identity__organizations",
|
||||
queryset=SlackUserGroup.objects,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
class Meta(ScheduleWebSerializer.Meta):
|
||||
fields = [*ScheduleBaseSerializer.Meta.fields, "slack_channel_id", "time_zone"]
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
updated_schedule = super().update(instance, validated_data)
|
||||
|
||||
old_time_zone = instance.time_zone
|
||||
updated_time_zone = updated_schedule.time_zone
|
||||
if old_time_zone != updated_time_zone:
|
||||
updated_schedule.drop_cached_ical()
|
||||
updated_schedule.check_empty_shifts_for_next_week()
|
||||
updated_schedule.check_gaps_for_next_week()
|
||||
schedule_notify_about_empty_shifts_in_schedule.apply_async((instance.pk,))
|
||||
schedule_notify_about_gaps_in_schedule.apply_async((instance.pk,))
|
||||
return updated_schedule
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
import time
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from rest_framework import serializers
|
||||
|
||||
|
|
@ -9,6 +12,7 @@ from apps.base.utils import live_settings
|
|||
from apps.oss_installation.utils import cloud_user_identity_status
|
||||
from apps.twilioapp.utils import check_phone_number_is_valid
|
||||
from apps.user_management.models import User
|
||||
from apps.user_management.models.user import default_working_hours
|
||||
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
|
||||
from common.api_helpers.mixins import EagerLoadingMixin
|
||||
from common.constants.role import Role
|
||||
|
|
@ -29,6 +33,7 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
|
|||
organization = FastOrganizationSerializer(read_only=True)
|
||||
current_team = TeamPrimaryKeyRelatedField(allow_null=True, required=False)
|
||||
|
||||
timezone = serializers.CharField(allow_null=True, required=False)
|
||||
avatar = serializers.URLField(source="avatar_url", read_only=True)
|
||||
|
||||
permissions = serializers.SerializerMethodField()
|
||||
|
|
@ -47,6 +52,8 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
|
|||
"username",
|
||||
"role",
|
||||
"avatar",
|
||||
"timezone",
|
||||
"working_hours",
|
||||
"unverified_phone_number",
|
||||
"verified_phone_number",
|
||||
"slack_user_identity",
|
||||
|
|
@ -63,6 +70,52 @@ class UserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
|
|||
"verified_phone_number",
|
||||
]
|
||||
|
||||
def validate_timezone(self, tz):
|
||||
if tz is None:
|
||||
return tz
|
||||
|
||||
try:
|
||||
pytz.timezone(tz)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
raise serializers.ValidationError("not a valid timezone")
|
||||
|
||||
return tz
|
||||
|
||||
def validate_working_hours(self, working_hours):
|
||||
if not isinstance(working_hours, dict):
|
||||
raise serializers.ValidationError("must be dict")
|
||||
|
||||
# check that all days are present
|
||||
if sorted(working_hours.keys()) != sorted(default_working_hours().keys()):
|
||||
raise serializers.ValidationError("missing some days")
|
||||
|
||||
for day in working_hours:
|
||||
periods = working_hours[day]
|
||||
|
||||
if not isinstance(periods, list):
|
||||
raise serializers.ValidationError("periods must be list")
|
||||
|
||||
for period in periods:
|
||||
if not isinstance(period, dict):
|
||||
raise serializers.ValidationError("period must be dict")
|
||||
|
||||
if sorted(period.keys()) != sorted(["start", "end"]):
|
||||
raise serializers.ValidationError("'start' and 'end' fields must be present")
|
||||
|
||||
if not isinstance(period["start"], str) or not isinstance(period["end"], str):
|
||||
raise serializers.ValidationError("'start' and 'end' fields must be str")
|
||||
|
||||
try:
|
||||
start = time.strptime(period["start"], "%H:%M:%S")
|
||||
end = time.strptime(period["end"], "%H:%M:%S")
|
||||
except ValueError:
|
||||
raise serializers.ValidationError("'start' and 'end' fields must be in '%H:%M:%S' format")
|
||||
|
||||
if start >= end:
|
||||
raise serializers.ValidationError("'start' must be less than 'end'")
|
||||
|
||||
return working_hours
|
||||
|
||||
def validate_unverified_phone_number(self, value):
|
||||
if value:
|
||||
if check_phone_number_is_valid(value):
|
||||
|
|
@ -110,6 +163,8 @@ class UserHiddenFieldsSerializer(UserSerializer):
|
|||
"current_team",
|
||||
"username",
|
||||
"avatar",
|
||||
"timezone",
|
||||
"working_hours",
|
||||
"notification_chain_verbal",
|
||||
"permissions",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ from rest_framework.response import Response
|
|||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleICal
|
||||
from apps.schedules.models import (
|
||||
CustomOnCallShift,
|
||||
OnCallSchedule,
|
||||
OnCallScheduleCalendar,
|
||||
OnCallScheduleICal,
|
||||
OnCallScheduleWeb,
|
||||
)
|
||||
from common.constants.role import Role
|
||||
|
||||
ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics"
|
||||
|
|
@ -41,12 +47,18 @@ def schedule_internal_api_setup(
|
|||
ical_url_primary=ICAL_URL,
|
||||
)
|
||||
|
||||
return user, token, calendar_schedule, ical_schedule, slack_channel
|
||||
web_schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_web_schedule",
|
||||
)
|
||||
|
||||
return user, token, calendar_schedule, ical_schedule, web_schedule, slack_channel
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_list_schedules(schedule_internal_api_setup, make_user_auth_headers):
|
||||
user, token, calendar_schedule, ical_schedule, slack_channel = schedule_internal_api_setup
|
||||
user, token, calendar_schedule, ical_schedule, web_schedule, slack_channel = schedule_internal_api_setup
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:schedule-list")
|
||||
|
||||
|
|
@ -85,6 +97,22 @@ def test_get_list_schedules(schedule_internal_api_setup, make_user_auth_headers)
|
|||
"notify_empty_oncall": 0,
|
||||
"notify_oncall_shift_freq": 1,
|
||||
},
|
||||
{
|
||||
"id": web_schedule.public_primary_key,
|
||||
"type": 2,
|
||||
"time_zone": "UTC",
|
||||
"team": None,
|
||||
"name": "test_web_schedule",
|
||||
"slack_channel": None,
|
||||
"user_group": None,
|
||||
"warnings": [],
|
||||
"on_call_now": [],
|
||||
"has_gaps": False,
|
||||
"mention_oncall_next": False,
|
||||
"mention_oncall_start": True,
|
||||
"notify_empty_oncall": 0,
|
||||
"notify_oncall_shift_freq": 1,
|
||||
},
|
||||
]
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
|
@ -93,7 +121,7 @@ def test_get_list_schedules(schedule_internal_api_setup, make_user_auth_headers)
|
|||
|
||||
@pytest.mark.django_db
|
||||
def test_get_detail_calendar_schedule(schedule_internal_api_setup, make_user_auth_headers):
|
||||
user, token, calendar_schedule, _, _ = schedule_internal_api_setup
|
||||
user, token, calendar_schedule, _, _, _ = schedule_internal_api_setup
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:schedule-detail", kwargs={"pk": calendar_schedule.public_primary_key})
|
||||
|
||||
|
|
@ -122,7 +150,7 @@ def test_get_detail_calendar_schedule(schedule_internal_api_setup, make_user_aut
|
|||
|
||||
@pytest.mark.django_db
|
||||
def test_get_detail_ical_schedule(schedule_internal_api_setup, make_user_auth_headers):
|
||||
user, token, _, ical_schedule, _ = schedule_internal_api_setup
|
||||
user, token, _, ical_schedule, _, _ = schedule_internal_api_setup
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:schedule-detail", kwargs={"pk": ical_schedule.public_primary_key})
|
||||
|
||||
|
|
@ -149,9 +177,37 @@ def test_get_detail_ical_schedule(schedule_internal_api_setup, make_user_auth_he
|
|||
assert response.data == expected_payload
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_detail_web_schedule(schedule_internal_api_setup, make_user_auth_headers):
|
||||
user, token, _, _, web_schedule, _ = schedule_internal_api_setup
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:schedule-detail", kwargs={"pk": web_schedule.public_primary_key})
|
||||
|
||||
expected_payload = {
|
||||
"id": web_schedule.public_primary_key,
|
||||
"team": None,
|
||||
"name": "test_web_schedule",
|
||||
"type": 2,
|
||||
"time_zone": "UTC",
|
||||
"slack_channel": None,
|
||||
"user_group": None,
|
||||
"warnings": [],
|
||||
"on_call_now": [],
|
||||
"has_gaps": False,
|
||||
"mention_oncall_next": False,
|
||||
"mention_oncall_start": True,
|
||||
"notify_empty_oncall": 0,
|
||||
"notify_oncall_shift_freq": 1,
|
||||
}
|
||||
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == expected_payload
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_calendar_schedule(schedule_internal_api_setup, make_user_auth_headers):
|
||||
user, token, _, _, _ = schedule_internal_api_setup
|
||||
user, token, _, _, _, _ = schedule_internal_api_setup
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:schedule-list")
|
||||
data = {
|
||||
|
|
@ -180,7 +236,7 @@ def test_create_calendar_schedule(schedule_internal_api_setup, make_user_auth_he
|
|||
|
||||
@pytest.mark.django_db
|
||||
def test_create_ical_schedule(schedule_internal_api_setup, make_user_auth_headers):
|
||||
user, token, _, _, _ = schedule_internal_api_setup
|
||||
user, token, _, _, _, _ = schedule_internal_api_setup
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:schedule-list")
|
||||
with patch(
|
||||
|
|
@ -210,9 +266,37 @@ def test_create_ical_schedule(schedule_internal_api_setup, make_user_auth_header
|
|||
assert response.data == data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_web_schedule(schedule_internal_api_setup, make_user_auth_headers):
|
||||
user, token, _, _, _, _ = schedule_internal_api_setup
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:schedule-list")
|
||||
data = {
|
||||
"name": "created_web_schedule",
|
||||
"type": 2,
|
||||
"time_zone": "UTC",
|
||||
"slack_channel_id": None,
|
||||
"user_group": None,
|
||||
"team": None,
|
||||
"warnings": [],
|
||||
"on_call_now": [],
|
||||
"has_gaps": False,
|
||||
"mention_oncall_next": False,
|
||||
"mention_oncall_start": True,
|
||||
"notify_empty_oncall": 0,
|
||||
"notify_oncall_shift_freq": 1,
|
||||
}
|
||||
response = client.post(url, data, format="json", **make_user_auth_headers(user, token))
|
||||
# modify initial data by adding id and None for optional fields
|
||||
schedule = OnCallSchedule.objects.get(public_primary_key=response.data["id"])
|
||||
data["id"] = schedule.public_primary_key
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data == data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_invalid_ical_schedule(schedule_internal_api_setup, make_user_auth_headers):
|
||||
user, token, _, ical_schedule, _ = schedule_internal_api_setup
|
||||
user, token, _, ical_schedule, _, _ = schedule_internal_api_setup
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:custom_button-list")
|
||||
with patch(
|
||||
|
|
@ -231,7 +315,7 @@ def test_create_invalid_ical_schedule(schedule_internal_api_setup, make_user_aut
|
|||
|
||||
@pytest.mark.django_db
|
||||
def test_update_calendar_schedule(schedule_internal_api_setup, make_user_auth_headers):
|
||||
user, token, calendar_schedule, _, _ = schedule_internal_api_setup
|
||||
user, token, calendar_schedule, _, _, _ = schedule_internal_api_setup
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:schedule-detail", kwargs={"pk": calendar_schedule.public_primary_key})
|
||||
|
|
@ -250,7 +334,7 @@ def test_update_calendar_schedule(schedule_internal_api_setup, make_user_auth_he
|
|||
|
||||
@pytest.mark.django_db
|
||||
def test_update_ical_schedule(schedule_internal_api_setup, make_user_auth_headers):
|
||||
user, token, _, ical_schedule, _ = schedule_internal_api_setup
|
||||
user, token, _, ical_schedule, _, _ = schedule_internal_api_setup
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:schedule-detail", kwargs={"pk": ical_schedule.public_primary_key})
|
||||
|
|
@ -267,9 +351,28 @@ def test_update_ical_schedule(schedule_internal_api_setup, make_user_auth_header
|
|||
assert updated_instance.name == "updated_ical_schedule"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_web_schedule(schedule_internal_api_setup, make_user_auth_headers):
|
||||
user, token, _, _, web_schedule, _ = schedule_internal_api_setup
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-internal:schedule-detail", kwargs={"pk": web_schedule.public_primary_key})
|
||||
data = {
|
||||
"name": "updated_web_schedule",
|
||||
"type": 2,
|
||||
"team": None,
|
||||
}
|
||||
response = client.put(
|
||||
url, data=json.dumps(data), content_type="application/json", **make_user_auth_headers(user, token)
|
||||
)
|
||||
updated_instance = OnCallSchedule.objects.get(public_primary_key=web_schedule.public_primary_key)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert updated_instance.name == "updated_web_schedule"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_schedule(schedule_internal_api_setup, make_user_auth_headers):
|
||||
user, token, calendar_schedule, ical_schedule, _ = schedule_internal_api_setup
|
||||
user, token, calendar_schedule, ical_schedule, _, _ = schedule_internal_api_setup
|
||||
client = APIClient()
|
||||
|
||||
for calendar in (calendar_schedule, ical_schedule):
|
||||
|
|
@ -326,6 +429,152 @@ def test_events_calendar(
|
|||
"calendar_type": OnCallSchedule.PRIMARY,
|
||||
"is_empty": False,
|
||||
"is_gap": False,
|
||||
"shift": {
|
||||
"pk": on_call_shift.public_primary_key,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == expected_result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_filter_events_calendar(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
client = APIClient()
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_web_schedule",
|
||||
)
|
||||
|
||||
now = timezone.now().replace(microsecond=0)
|
||||
start_date = now - timezone.timedelta(days=7)
|
||||
data = {
|
||||
"start": start_date,
|
||||
"duration": timezone.timedelta(seconds=7200),
|
||||
"priority_level": 1,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
"by_day": ["MO", "FR"],
|
||||
"schedule": schedule,
|
||||
}
|
||||
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data
|
||||
)
|
||||
on_call_shift.users.add(user)
|
||||
|
||||
url = reverse("api-internal:schedule-filter-events", kwargs={"pk": schedule.public_primary_key})
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
# current week events are expected
|
||||
mon_start = now - timezone.timedelta(days=start_date.weekday())
|
||||
fri_start = mon_start + timezone.timedelta(days=4)
|
||||
expected_result = {
|
||||
"id": schedule.public_primary_key,
|
||||
"name": "test_web_schedule",
|
||||
"type": 2,
|
||||
"events": [
|
||||
{
|
||||
"all_day": False,
|
||||
"start": mon_start,
|
||||
"end": mon_start + on_call_shift.duration,
|
||||
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
|
||||
"priority_level": on_call_shift.priority_level,
|
||||
"source": "api",
|
||||
"calendar_type": OnCallSchedule.PRIMARY,
|
||||
"is_empty": False,
|
||||
"is_gap": False,
|
||||
"shift": {
|
||||
"pk": on_call_shift.public_primary_key,
|
||||
},
|
||||
},
|
||||
{
|
||||
"all_day": False,
|
||||
"start": fri_start,
|
||||
"end": fri_start + on_call_shift.duration,
|
||||
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
|
||||
"priority_level": on_call_shift.priority_level,
|
||||
"source": "api",
|
||||
"calendar_type": OnCallSchedule.PRIMARY,
|
||||
"is_empty": False,
|
||||
"is_gap": False,
|
||||
"shift": {
|
||||
"pk": on_call_shift.public_primary_key,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == expected_result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_filter_events_range_calendar(
|
||||
make_organization_and_user_with_plugin_token,
|
||||
make_user_auth_headers,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_plugin_token()
|
||||
client = APIClient()
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
name="test_web_schedule",
|
||||
)
|
||||
|
||||
now = timezone.now().replace(microsecond=0)
|
||||
start_date = now - timezone.timedelta(days=7)
|
||||
data = {
|
||||
"start": start_date,
|
||||
"duration": timezone.timedelta(seconds=7200),
|
||||
"priority_level": 1,
|
||||
"frequency": CustomOnCallShift.FREQUENCY_WEEKLY,
|
||||
"by_day": ["MO", "FR"],
|
||||
"schedule": schedule,
|
||||
}
|
||||
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data
|
||||
)
|
||||
on_call_shift.users.add(user)
|
||||
|
||||
mon_start = now - timezone.timedelta(days=start_date.weekday())
|
||||
request_date = mon_start + timezone.timedelta(days=2)
|
||||
|
||||
url = reverse("api-internal:schedule-filter-events", kwargs={"pk": schedule.public_primary_key})
|
||||
url += "?date={}&days=3".format(request_date.strftime("%Y-%m-%d"))
|
||||
response = client.get(url, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
# only friday occurrence is expected
|
||||
fri_start = mon_start + timezone.timedelta(days=4)
|
||||
expected_result = {
|
||||
"id": schedule.public_primary_key,
|
||||
"name": "test_web_schedule",
|
||||
"type": 2,
|
||||
"events": [
|
||||
{
|
||||
"all_day": False,
|
||||
"start": fri_start,
|
||||
"end": fri_start + on_call_shift.duration,
|
||||
"users": [{"display_name": user.username, "pk": user.public_primary_key}],
|
||||
"priority_level": on_call_shift.priority_level,
|
||||
"source": "api",
|
||||
"calendar_type": OnCallSchedule.PRIMARY,
|
||||
"is_empty": False,
|
||||
"is_gap": False,
|
||||
"shift": {
|
||||
"pk": on_call_shift.public_primary_key,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from rest_framework.test import APIClient
|
|||
|
||||
from apps.base.constants import ADMIN_PERMISSIONS, EDITOR_PERMISSIONS
|
||||
from apps.base.models import UserNotificationPolicy
|
||||
from apps.user_management.models.user import default_working_hours
|
||||
from common.constants.role import Role
|
||||
|
||||
|
||||
|
|
@ -67,6 +68,8 @@ def test_update_user_cant_change_email_and_username(
|
|||
"email": admin.email,
|
||||
"username": admin.username,
|
||||
"role": admin.role,
|
||||
"timezone": None,
|
||||
"working_hours": default_working_hours(),
|
||||
"unverified_phone_number": phone_number,
|
||||
"verified_phone_number": None,
|
||||
"telegram_configuration": None,
|
||||
|
|
@ -113,6 +116,8 @@ def test_list_users(
|
|||
"email": admin.email,
|
||||
"username": admin.username,
|
||||
"role": admin.role,
|
||||
"timezone": None,
|
||||
"working_hours": default_working_hours(),
|
||||
"unverified_phone_number": None,
|
||||
"verified_phone_number": None,
|
||||
"telegram_configuration": None,
|
||||
|
|
@ -134,6 +139,8 @@ def test_list_users(
|
|||
"email": editor.email,
|
||||
"username": editor.username,
|
||||
"role": editor.role,
|
||||
"timezone": None,
|
||||
"working_hours": default_working_hours(),
|
||||
"unverified_phone_number": None,
|
||||
"verified_phone_number": None,
|
||||
"telegram_configuration": None,
|
||||
|
|
@ -1485,3 +1492,103 @@ def test_viewer_cant_unlink_backend_another_user(
|
|||
|
||||
response = client.post(url, format="json", **make_user_auth_headers(second_user, token))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_timezone(
|
||||
make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers
|
||||
):
|
||||
organization = make_organization()
|
||||
user = make_user_for_organization(organization, role=Role.EDITOR)
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key})
|
||||
|
||||
data = {"timezone": "Europe/London"}
|
||||
|
||||
response = client.put(f"{url}", data, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "timezone" in response.json()
|
||||
assert response.json()["timezone"] == "Europe/London"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("timezone", ["", 1, "NotATimezone"])
|
||||
def test_invalid_timezone(
|
||||
make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers, timezone
|
||||
):
|
||||
organization = make_organization()
|
||||
user = make_user_for_organization(organization, role=Role.EDITOR)
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key})
|
||||
|
||||
data = {"timezone": timezone}
|
||||
|
||||
response = client.put(f"{url}", data, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_working_hours(
|
||||
make_organization, make_user_for_organization, make_token_for_organization, make_user_auth_headers
|
||||
):
|
||||
organization = make_organization()
|
||||
user = make_user_for_organization(organization, role=Role.EDITOR)
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key})
|
||||
|
||||
periods = [{"start": "05:00:00", "end": "23:00:00"}]
|
||||
working_hours = {
|
||||
day: periods for day in ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
||||
}
|
||||
|
||||
data = {"working_hours": working_hours}
|
||||
|
||||
response = client.put(f"{url}", data, format="json", **make_user_auth_headers(user, token))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "working_hours" in response.json()
|
||||
assert response.json()["working_hours"] == working_hours
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"working_hours_extra",
|
||||
[
|
||||
{},
|
||||
{"sunday": 1},
|
||||
{"sunday": ""},
|
||||
{"sunday": {"start": "18:00:00"}},
|
||||
{"sunday": {"start": "", "end": ""}},
|
||||
{"sunday": {"start": "18:00:00", "end": None}},
|
||||
{"sunday": {"start": "18:00:00", "end": "18:00:00"}},
|
||||
{"sunday": {"start": "18:00:00", "end": "9:00:00"}},
|
||||
{"sunday": {"start": "18:00:00", "end": "9:00:00", "extra": 1}},
|
||||
],
|
||||
)
|
||||
def test_invalid_working_hours(
|
||||
make_organization,
|
||||
make_user_for_organization,
|
||||
make_token_for_organization,
|
||||
make_user_auth_headers,
|
||||
working_hours_extra,
|
||||
):
|
||||
organization = make_organization()
|
||||
user = make_user_for_organization(organization, role=Role.EDITOR)
|
||||
_, token = make_token_for_organization(organization)
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("api-internal:user-detail", kwargs={"pk": user.public_primary_key})
|
||||
|
||||
periods = [{"start": "05:00:00", "end": "23:00:00"}]
|
||||
working_hours = {day: periods for day in ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday"]}
|
||||
working_hours.update(working_hours_extra)
|
||||
|
||||
data = {"working_hours": working_hours}
|
||||
response = client.put(f"{url}", data, format="json", **make_user_auth_headers(user, token))
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ class ScheduleView(
|
|||
AnyRole: (
|
||||
*READ_ACTIONS,
|
||||
"events",
|
||||
"filter_events",
|
||||
"notify_empty_oncall_options",
|
||||
"notify_oncall_shift_freq_options",
|
||||
"mention_options",
|
||||
|
|
@ -189,14 +190,11 @@ class ScheduleView(
|
|||
|
||||
return user_tz, date
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def events(self, request, pk):
|
||||
user_tz, date = self.get_request_timezone()
|
||||
with_empty = self.request.query_params.get("with_empty", False) == "true"
|
||||
with_gap = self.request.query_params.get("with_gap", False) == "true"
|
||||
schedule = self.original_get_object()
|
||||
shifts = list_of_oncall_shifts_from_ical(schedule, date, user_tz, with_empty, with_gap) or []
|
||||
events_result = []
|
||||
def _filter_events(self, schedule, timezone, starting_date, days, with_empty, with_gap):
|
||||
shifts = (
|
||||
list_of_oncall_shifts_from_ical(schedule, starting_date, timezone, with_empty, with_gap, days=days) or []
|
||||
)
|
||||
events = []
|
||||
# for start, end, users, priority_level, source in shifts:
|
||||
for shift in shifts:
|
||||
all_day = type(shift["start"]) == datetime.date
|
||||
|
|
@ -218,8 +216,22 @@ class ScheduleView(
|
|||
"calendar_type": shift["calendar_type"],
|
||||
"is_empty": len(shift["users"]) == 0 and not is_gap,
|
||||
"is_gap": is_gap,
|
||||
"shift": {
|
||||
"pk": shift["shift_pk"],
|
||||
},
|
||||
}
|
||||
events_result.append(shift_json)
|
||||
events.append(shift_json)
|
||||
|
||||
return events
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def events(self, request, pk):
|
||||
user_tz, date = self.get_request_timezone()
|
||||
with_empty = self.request.query_params.get("with_empty", False) == "true"
|
||||
with_gap = self.request.query_params.get("with_gap", False) == "true"
|
||||
|
||||
schedule = self.original_get_object()
|
||||
events = self._filter_events(schedule, user_tz, date, days=1, with_empty=with_empty, with_gap=with_gap)
|
||||
|
||||
slack_channel = (
|
||||
{
|
||||
|
|
@ -236,7 +248,36 @@ class ScheduleView(
|
|||
"name": schedule.name,
|
||||
"type": PolymorphicScheduleSerializer().to_resource_type(schedule),
|
||||
"slack_channel": slack_channel,
|
||||
"events": events_result,
|
||||
"events": events,
|
||||
}
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def filter_events(self, request, pk):
|
||||
user_tz, date = self.get_request_timezone()
|
||||
with_empty = self.request.query_params.get("with_empty", False) == "true"
|
||||
with_gap = self.request.query_params.get("with_gap", False) == "true"
|
||||
|
||||
starting_date = date if self.request.query_params.get("date") else None
|
||||
if starting_date is None:
|
||||
# default to current week start
|
||||
starting_date = date - datetime.timedelta(days=date.weekday())
|
||||
|
||||
try:
|
||||
days = int(self.request.query_params.get("days", 7)) # fallback to a week
|
||||
except ValueError:
|
||||
raise BadRequest(detail="Invalid days format")
|
||||
|
||||
schedule = self.original_get_object()
|
||||
events = self._filter_events(
|
||||
schedule, user_tz, starting_date, days=days, with_empty=with_empty, with_gap=with_gap
|
||||
)
|
||||
|
||||
result = {
|
||||
"id": schedule.public_primary_key,
|
||||
"name": schedule.name,
|
||||
"type": PolymorphicScheduleSerializer().to_resource_type(schedule),
|
||||
"events": events,
|
||||
}
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import logging
|
||||
|
||||
import pytz
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
|
@ -123,7 +124,7 @@ class UserView(
|
|||
"mobile_app_verification_token",
|
||||
"mobile_app_auth_token",
|
||||
),
|
||||
AnyRole: ("retrieve",),
|
||||
AnyRole: ("retrieve", "timezone_options"),
|
||||
}
|
||||
|
||||
action_object_permissions = {
|
||||
|
|
@ -236,6 +237,10 @@ class UserView(
|
|||
serializer = UserSerializer(self.get_queryset().get(pk=self.request.user.pk))
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def timezone_options(self, request):
|
||||
return Response(pytz.common_timezones)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def get_verification_code(self, request, pk):
|
||||
user = self.get_object()
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class BaseMessagingBackend:
|
|||
if self.templater:
|
||||
return import_string(self.templater)
|
||||
|
||||
def validate_channel_filter_data(self, channel_filter, data):
|
||||
def validate_channel_filter_data(self, organization, data):
|
||||
"""Validate JSON channel data for a channel filter update.
|
||||
|
||||
Ensure the required/expected data is provided as needed by the backend.
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer
|
|||
return result
|
||||
|
||||
def _validate_frequency_and_week_start(self, event_type, frequency, week_start):
|
||||
if event_type != CustomOnCallShift.TYPE_SINGLE_EVENT:
|
||||
if event_type not in (CustomOnCallShift.TYPE_SINGLE_EVENT, CustomOnCallShift.TYPE_OVERRIDE):
|
||||
if frequency is None:
|
||||
raise BadRequest(detail="Field 'frequency' is required for this on-call shift type")
|
||||
elif frequency == CustomOnCallShift.FREQUENCY_WEEKLY and week_start is None:
|
||||
|
|
@ -266,6 +266,18 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer
|
|||
],
|
||||
CustomOnCallShift.TYPE_RECURRENT_EVENT: ["rolling_users", "start_rotation_from_user_index"],
|
||||
CustomOnCallShift.TYPE_ROLLING_USERS_EVENT: ["users"],
|
||||
CustomOnCallShift.TYPE_OVERRIDE: [
|
||||
"level",
|
||||
"frequency",
|
||||
"interval",
|
||||
"until",
|
||||
"by_day",
|
||||
"by_month",
|
||||
"by_monthday",
|
||||
"week_start",
|
||||
"rolling_users",
|
||||
"start_rotation_from_user_index",
|
||||
],
|
||||
}
|
||||
for field in fields_to_remove_map[event_type]:
|
||||
result.pop(field, None)
|
||||
|
|
@ -289,9 +301,24 @@ class CustomOnCallShiftSerializer(EagerLoadingMixin, serializers.ModelSerializer
|
|||
],
|
||||
CustomOnCallShift.TYPE_RECURRENT_EVENT: ["rolling_users", "start_rotation_from_user_index"],
|
||||
CustomOnCallShift.TYPE_ROLLING_USERS_EVENT: ["users"],
|
||||
CustomOnCallShift.TYPE_OVERRIDE: [
|
||||
"priority_level",
|
||||
"frequency",
|
||||
"interval",
|
||||
"by_day",
|
||||
"by_month",
|
||||
"by_monthday",
|
||||
"rolling_users",
|
||||
"start_rotation_from_user_index",
|
||||
],
|
||||
}
|
||||
for field in fields_to_update_map[event_type]:
|
||||
validated_data[field] = None if field != "users" else []
|
||||
value = None
|
||||
if field == "users":
|
||||
value = []
|
||||
elif field == "priority_level":
|
||||
value = 0
|
||||
validated_data[field] = value
|
||||
|
||||
validated_data_list_fields = ["by_day", "by_month", "by_monthday", "rolling_users"]
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,136 @@ from django.apps import apps
|
|||
from rest_framework import serializers
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel, ChannelFilter, EscalationChain
|
||||
from apps.base.messaging import get_messaging_backend_from_id, get_messaging_backends
|
||||
from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.mixins import OrderedModelSerializerMixin
|
||||
|
||||
|
||||
class ChannelFilterSerializer(OrderedModelSerializerMixin, serializers.ModelSerializer):
|
||||
class BaseChannelFilterSerializer(OrderedModelSerializerMixin, serializers.ModelSerializer):
|
||||
"""Base Channel Filter serializer with validation methods"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Update existing fields of the serializer with messaging backends fields"""
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
for backend_id, backend in get_messaging_backends():
|
||||
if backend is None:
|
||||
continue
|
||||
field = backend_id.lower()
|
||||
self._declared_fields[field] = serializers.DictField(required=False)
|
||||
self.Meta.fields.append(field)
|
||||
|
||||
def to_representation(self, instance):
|
||||
result = super().to_representation(instance)
|
||||
result["slack"] = {"channel_id": instance.slack_channel_id, "enabled": bool(instance.notify_in_slack)}
|
||||
result["telegram"] = {
|
||||
"id": instance.telegram_channel.public_primary_key if instance.telegram_channel else None,
|
||||
"enabled": bool(instance.notify_in_telegram),
|
||||
}
|
||||
# add representation for other messaging backends
|
||||
for backend_id, backend in get_messaging_backends():
|
||||
if backend is None:
|
||||
continue
|
||||
field = backend_id.lower()
|
||||
channel_id = None
|
||||
notification_enabled = False
|
||||
if instance.notification_backends and instance.notification_backends.get(backend_id):
|
||||
channel_id = instance.notification_backends[backend_id].get("channel")
|
||||
notification_enabled = bool(instance.notification_backends[backend_id].get("enabled"))
|
||||
result[field] = {"id": channel_id, "enabled": notification_enabled}
|
||||
return result
|
||||
|
||||
def _correct_validated_data(self, validated_data):
|
||||
organization = self.context["request"].auth.organization
|
||||
|
||||
slack_field = validated_data.pop("slack", {})
|
||||
if slack_field:
|
||||
if "channel_id" in slack_field:
|
||||
validated_data["slack_channel_id"] = self._validate_slack_channel_id(slack_field.get("channel_id"))
|
||||
if "enabled" in slack_field:
|
||||
validated_data["notify_in_slack"] = bool(slack_field.get("enabled"))
|
||||
|
||||
telegram_field = validated_data.pop("telegram", {})
|
||||
if telegram_field:
|
||||
if "id" in telegram_field:
|
||||
validated_data["telegram_channel"] = self._validate_telegram_channel(telegram_field.get("id"))
|
||||
if "enabled" in telegram_field:
|
||||
validated_data["notify_in_telegram"] = bool(telegram_field.get("enabled"))
|
||||
|
||||
notification_backends = {}
|
||||
for backend_id, backend in get_messaging_backends():
|
||||
if backend is None:
|
||||
continue
|
||||
field = backend_id.lower()
|
||||
backend_field = validated_data.pop(field, {})
|
||||
if backend_field:
|
||||
notification_backend = {}
|
||||
if "id" in backend_field:
|
||||
notification_backend["channel"] = backend_field["id"]
|
||||
if "enabled" in backend_field:
|
||||
notification_backend["enabled"] = backend_field["enabled"]
|
||||
backend.validate_channel_filter_data(organization, notification_backend)
|
||||
notification_backends[backend_id] = notification_backend
|
||||
if notification_backends:
|
||||
validated_data["notification_backends"] = notification_backends
|
||||
return validated_data
|
||||
|
||||
def _validate_slack_channel_id(self, slack_channel_id):
|
||||
SlackChannel = apps.get_model("slack", "SlackChannel")
|
||||
|
||||
if slack_channel_id is not None:
|
||||
slack_channel_id = slack_channel_id.upper()
|
||||
organization = self.context["request"].auth.organization
|
||||
slack_team_identity = organization.slack_team_identity
|
||||
try:
|
||||
slack_team_identity.get_cached_channels().get(slack_id=slack_channel_id)
|
||||
except SlackChannel.DoesNotExist:
|
||||
raise BadRequest(detail="Slack channel does not exist")
|
||||
return slack_channel_id
|
||||
|
||||
def _validate_telegram_channel(self, telegram_channel_id):
|
||||
TelegramToOrganizationConnector = apps.get_model("telegram", "TelegramToOrganizationConnector")
|
||||
if telegram_channel_id is not None:
|
||||
organization = self.context["request"].auth.organization
|
||||
try:
|
||||
telegram_channel = organization.telegram_channel.get(public_primary_key=telegram_channel_id)
|
||||
except TelegramToOrganizationConnector.DoesNotExist:
|
||||
raise BadRequest(detail="Telegram channel does not exist")
|
||||
return telegram_channel
|
||||
return
|
||||
|
||||
def _update_notification_backends(self, notification_backends):
|
||||
if notification_backends is not None:
|
||||
current = self.instance.notification_backends or {}
|
||||
for backend_id in notification_backends:
|
||||
backend = get_messaging_backend_from_id(backend_id)
|
||||
if backend is None:
|
||||
continue
|
||||
# update existing backend data
|
||||
notification_backends[backend_id] = current.get(backend_id, {}) | notification_backends[backend_id]
|
||||
return notification_backends
|
||||
|
||||
def validate_escalation_chain_id(self, escalation_chain):
|
||||
if escalation_chain is None:
|
||||
return escalation_chain
|
||||
if self.instance is not None:
|
||||
alert_receive_channel = self.instance.alert_receive_channel
|
||||
else:
|
||||
alert_receive_channel = AlertReceiveChannel.objects.get(
|
||||
public_primary_key=self.initial_data["integration_id"]
|
||||
)
|
||||
|
||||
if escalation_chain.team != alert_receive_channel.team:
|
||||
raise BadRequest(detail="Escalation chain must be assigned to the same team as the integration")
|
||||
|
||||
return escalation_chain
|
||||
|
||||
|
||||
class ChannelFilterSerializer(BaseChannelFilterSerializer):
|
||||
id = serializers.CharField(read_only=True, source="public_primary_key")
|
||||
slack = serializers.DictField(required=False)
|
||||
telegram = serializers.DictField(required=False)
|
||||
routing_regex = serializers.CharField(allow_null=False, required=True, source="filtering_term")
|
||||
position = serializers.IntegerField(required=False, source="order")
|
||||
integration_id = OrganizationFilteredPrimaryKeyRelatedField(
|
||||
|
|
@ -33,15 +155,11 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, serializers.ModelSeri
|
|||
"position",
|
||||
"is_the_last_route",
|
||||
"slack",
|
||||
"telegram",
|
||||
"manual_order",
|
||||
]
|
||||
read_only_fields = ("is_the_last_route",)
|
||||
|
||||
def to_representation(self, instance):
|
||||
result = super().to_representation(instance)
|
||||
result["slack"] = {"channel_id": instance.slack_channel_id}
|
||||
return result
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data = self._correct_validated_data(validated_data)
|
||||
manual_order = validated_data.pop("manual_order")
|
||||
|
|
@ -71,42 +189,15 @@ class ChannelFilterSerializer(OrderedModelSerializerMixin, serializers.ModelSeri
|
|||
else:
|
||||
raise BadRequest(detail="Route with this regex already exists")
|
||||
|
||||
def validate_escalation_chain_id(self, escalation_chain):
|
||||
if self.instance is not None:
|
||||
alert_receive_channel = self.instance.alert_receive_channel
|
||||
else:
|
||||
alert_receive_channel = AlertReceiveChannel.objects.get(
|
||||
public_primary_key=self.initial_data["integration_id"]
|
||||
)
|
||||
|
||||
if escalation_chain.team != alert_receive_channel.team:
|
||||
raise BadRequest(detail="Escalation chain must be assigned to the same team as the integration")
|
||||
|
||||
return escalation_chain
|
||||
|
||||
def _correct_validated_data(self, validated_data):
|
||||
slack_field = validated_data.pop("slack", {})
|
||||
if "channel_id" in slack_field:
|
||||
validated_data["slack_channel_id"] = self._validate_slack_channel_id(slack_field.get("channel_id"))
|
||||
return validated_data
|
||||
|
||||
def _validate_slack_channel_id(self, slack_channel_id):
|
||||
SlackChannel = apps.get_model("slack", "SlackChannel")
|
||||
|
||||
if slack_channel_id is not None:
|
||||
slack_channel_id = slack_channel_id.upper()
|
||||
organization = self.context["request"].auth.organization
|
||||
slack_team_identity = organization.slack_team_identity
|
||||
try:
|
||||
slack_team_identity.get_cached_channels().get(slack_id=slack_channel_id)
|
||||
except SlackChannel.DoesNotExist:
|
||||
raise BadRequest(detail="Slack channel does not exist")
|
||||
return slack_channel_id
|
||||
|
||||
|
||||
class ChannelFilterUpdateSerializer(ChannelFilterSerializer):
|
||||
integration_id = OrganizationFilteredPrimaryKeyRelatedField(source="alert_receive_channel", read_only=True)
|
||||
routing_regex = serializers.CharField(allow_null=False, required=False, source="filtering_term")
|
||||
escalation_chain_id = OrganizationFilteredPrimaryKeyRelatedField(
|
||||
queryset=EscalationChain.objects,
|
||||
source="escalation_chain",
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta(ChannelFilterSerializer.Meta):
|
||||
read_only_fields = [*ChannelFilterSerializer.Meta.read_only_fields, "integration_id"]
|
||||
|
|
@ -122,12 +213,18 @@ class ChannelFilterUpdateSerializer(ChannelFilterSerializer):
|
|||
)
|
||||
self._change_position(order, instance)
|
||||
|
||||
if validated_data.get("notification_backends"):
|
||||
validated_data["notification_backends"] = self._update_notification_backends(
|
||||
validated_data["notification_backends"]
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class DefaultChannelFilterSerializer(OrderedModelSerializerMixin, serializers.ModelSerializer):
|
||||
class DefaultChannelFilterSerializer(BaseChannelFilterSerializer):
|
||||
id = serializers.CharField(read_only=True, source="public_primary_key")
|
||||
slack = serializers.DictField(required=False)
|
||||
telegram = serializers.DictField(required=False)
|
||||
escalation_chain_id = OrganizationFilteredPrimaryKeyRelatedField(
|
||||
queryset=EscalationChain.objects,
|
||||
source="escalation_chain",
|
||||
|
|
@ -140,48 +237,14 @@ class DefaultChannelFilterSerializer(OrderedModelSerializerMixin, serializers.Mo
|
|||
fields = [
|
||||
"id",
|
||||
"slack",
|
||||
"telegram",
|
||||
"escalation_chain_id",
|
||||
]
|
||||
|
||||
def _validate_slack_channel_id(self, slack_channel_id):
|
||||
SlackChannel = apps.get_model("slack", "SlackChannel")
|
||||
|
||||
if slack_channel_id is not None:
|
||||
slack_channel_id = slack_channel_id.upper()
|
||||
organization = self.context["request"].auth.organization
|
||||
slack_team_identity = organization.slack_team_identity
|
||||
try:
|
||||
slack_team_identity.get_cached_channels().get(slack_id=slack_channel_id)
|
||||
except SlackChannel.DoesNotExist:
|
||||
raise BadRequest(detail="Slack channel does not exist")
|
||||
return slack_channel_id
|
||||
|
||||
def _correct_validated_data(self, validated_data):
|
||||
slack_field = validated_data.pop("slack", {})
|
||||
if "channel_id" in slack_field:
|
||||
validated_data["slack_channel_id"] = self._validate_slack_channel_id(slack_field.get("channel_id"))
|
||||
return validated_data
|
||||
|
||||
def to_representation(self, instance):
|
||||
result = super().to_representation(instance)
|
||||
result["slack"] = {"channel_id": instance.slack_channel_id}
|
||||
return result
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data = self._correct_validated_data(validated_data)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def validate_escalation_chain_id(self, escalation_chain):
|
||||
if escalation_chain is None:
|
||||
return escalation_chain
|
||||
if self.instance is not None:
|
||||
alert_receive_channel = self.instance.alert_receive_channel
|
||||
else:
|
||||
alert_receive_channel = AlertReceiveChannel.objects.get(
|
||||
public_primary_key=self.initial_data["integration_id"]
|
||||
if validated_data.get("notification_backends"):
|
||||
validated_data["notification_backends"] = self._update_notification_backends(
|
||||
validated_data["notification_backends"]
|
||||
)
|
||||
|
||||
if escalation_chain.team != alert_receive_channel.team:
|
||||
raise BadRequest(detail="Escalation chain must be assigned to the same team as the integration")
|
||||
|
||||
return escalation_chain
|
||||
return super().update(instance, validated_data)
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ class ScheduleCalendarSerializer(ScheduleBaseSerializer):
|
|||
for shift in shifts:
|
||||
if shift.team_id != team_id:
|
||||
raise BadRequest(detail="Shifts must be assigned to the same team as the schedule")
|
||||
if shift.type == CustomOnCallShift.TYPE_OVERRIDE:
|
||||
raise BadRequest(detail="Shifts of type override are not supported in this schedule")
|
||||
|
||||
return shifts
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ from rest_polymorphic.serializers import PolymorphicSerializer
|
|||
|
||||
from apps.public_api.serializers.schedules_calendar import ScheduleCalendarSerializer, ScheduleCalendarUpdateSerializer
|
||||
from apps.public_api.serializers.schedules_ical import ScheduleICalSerializer, ScheduleICalUpdateSerializer
|
||||
from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal
|
||||
from apps.public_api.serializers.schedules_web import ScheduleWebSerializer, ScheduleWebUpdateSerializer
|
||||
from apps.schedules.models import OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb
|
||||
from common.api_helpers.mixins import EagerLoadingMixin
|
||||
|
||||
|
||||
|
|
@ -15,9 +16,10 @@ class PolymorphicScheduleSerializer(EagerLoadingMixin, PolymorphicSerializer):
|
|||
model_serializer_mapping = {
|
||||
OnCallScheduleICal: ScheduleICalSerializer,
|
||||
OnCallScheduleCalendar: ScheduleCalendarSerializer,
|
||||
OnCallScheduleWeb: ScheduleWebSerializer,
|
||||
}
|
||||
|
||||
SCHEDULE_CLASS_TO_TYPE = {OnCallScheduleCalendar: "calendar", OnCallScheduleICal: "ical"}
|
||||
SCHEDULE_CLASS_TO_TYPE = {OnCallScheduleCalendar: "calendar", OnCallScheduleICal: "ical", OnCallScheduleWeb: "web"}
|
||||
|
||||
def to_resource_type(self, model_or_instance):
|
||||
return self.SCHEDULE_CLASS_TO_TYPE.get(model_or_instance._meta.model)
|
||||
|
|
@ -27,6 +29,7 @@ class PolymorphicScheduleUpdateSerializer(PolymorphicScheduleSerializer):
|
|||
model_serializer_mapping = {
|
||||
OnCallScheduleICal: ScheduleICalUpdateSerializer,
|
||||
OnCallScheduleCalendar: ScheduleCalendarUpdateSerializer,
|
||||
OnCallScheduleWeb: ScheduleWebUpdateSerializer,
|
||||
}
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
|
|
|
|||
95
engine/apps/public_api/serializers/schedules_web.py
Normal file
95
engine/apps/public_api/serializers/schedules_web.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import pytz
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.public_api.serializers.schedules_base import ScheduleBaseSerializer
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb
|
||||
from apps.schedules.tasks import (
|
||||
drop_cached_ical_task,
|
||||
schedule_notify_about_empty_shifts_in_schedule,
|
||||
schedule_notify_about_gaps_in_schedule,
|
||||
)
|
||||
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField, UsersFilteredByOrganizationField
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
|
||||
|
||||
class ScheduleWebSerializer(ScheduleBaseSerializer):
|
||||
time_zone = serializers.CharField(required=True)
|
||||
shifts = UsersFilteredByOrganizationField(
|
||||
queryset=CustomOnCallShift.objects,
|
||||
required=False,
|
||||
source="custom_shifts",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = OnCallScheduleWeb
|
||||
fields = [
|
||||
"id",
|
||||
"team_id",
|
||||
"name",
|
||||
"time_zone",
|
||||
"slack",
|
||||
"on_call_now",
|
||||
"shifts",
|
||||
]
|
||||
|
||||
def validate_time_zone(self, tz):
|
||||
try:
|
||||
pytz.timezone(tz)
|
||||
except pytz.exceptions.UnknownTimeZoneError:
|
||||
raise BadRequest(detail="Invalid time zone")
|
||||
return tz
|
||||
|
||||
def validate_shifts(self, shifts):
|
||||
# Get team_id from instance, if it exists, otherwise get it from initial data.
|
||||
# Handle empty string instead of None. In this case change team_id value to None.
|
||||
team_id = self.instance.team_id if self.instance else (self.initial_data.get("team_id") or None)
|
||||
for shift in shifts:
|
||||
if shift.team_id != team_id:
|
||||
raise BadRequest(detail="Shifts must be assigned to the same team as the schedule")
|
||||
|
||||
return shifts
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if data.get("shifts", []) is None: # handle a None value
|
||||
data["shifts"] = []
|
||||
result = super().to_internal_value(data)
|
||||
return result
|
||||
|
||||
|
||||
class ScheduleWebUpdateSerializer(ScheduleWebSerializer):
|
||||
time_zone = serializers.CharField(required=False)
|
||||
team_id = TeamPrimaryKeyRelatedField(read_only=True, source="team")
|
||||
|
||||
class Meta:
|
||||
model = OnCallScheduleWeb
|
||||
fields = [
|
||||
"id",
|
||||
"team_id",
|
||||
"name",
|
||||
"time_zone",
|
||||
"slack",
|
||||
"on_call_now",
|
||||
"shifts",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"name": {"required": False},
|
||||
}
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data = self._correct_validated_data(validated_data)
|
||||
new_time_zone = validated_data.get("time_zone", instance.time_zone)
|
||||
new_shifts = validated_data.get("shifts", [])
|
||||
existing_shifts = instance.custom_shifts.all()
|
||||
|
||||
ical_changed = False
|
||||
|
||||
if new_time_zone != instance.time_zone or set(existing_shifts) != set(new_shifts):
|
||||
ical_changed = True
|
||||
|
||||
if ical_changed:
|
||||
drop_cached_ical_task.apply_async(
|
||||
(instance.pk,),
|
||||
)
|
||||
schedule_notify_about_empty_shifts_in_schedule.apply_async((instance.pk,))
|
||||
schedule_notify_about_gaps_in_schedule.apply_async((instance.pk,))
|
||||
return super().update(instance, validated_data)
|
||||
|
|
@ -3,6 +3,10 @@ from django.urls import reverse
|
|||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.base.tests.messaging_backend import TestOnlyBackend
|
||||
|
||||
TEST_MESSAGING_BACKEND_FIELD = TestOnlyBackend.backend_id.lower()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_list_integrations(
|
||||
|
|
@ -31,7 +35,9 @@ def test_get_list_integrations(
|
|||
"default_route": {
|
||||
"escalation_chain_id": None,
|
||||
"id": default_channel_filter.public_primary_key,
|
||||
"slack": {"channel_id": None},
|
||||
"slack": {"channel_id": None, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False},
|
||||
},
|
||||
"heartbeat": {
|
||||
"link": f"{integration.integration_url}heartbeat/",
|
||||
|
|
@ -159,7 +165,9 @@ def test_update_integration_template(
|
|||
"default_route": {
|
||||
"escalation_chain_id": None,
|
||||
"id": default_channel_filter.public_primary_key,
|
||||
"slack": {"channel_id": None},
|
||||
"slack": {"channel_id": None, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False},
|
||||
},
|
||||
"heartbeat": {
|
||||
"link": f"{integration.integration_url}heartbeat/",
|
||||
|
|
@ -232,7 +240,9 @@ def test_update_resolve_signal_template(
|
|||
"default_route": {
|
||||
"escalation_chain_id": None,
|
||||
"id": default_channel_filter.public_primary_key,
|
||||
"slack": {"channel_id": None},
|
||||
"slack": {"channel_id": None, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False},
|
||||
},
|
||||
"heartbeat": {
|
||||
"link": f"{integration.integration_url}heartbeat/",
|
||||
|
|
@ -337,7 +347,9 @@ def test_update_sms_template_with_empty_dict(
|
|||
"default_route": {
|
||||
"escalation_chain_id": None,
|
||||
"id": default_channel_filter.public_primary_key,
|
||||
"slack": {"channel_id": None},
|
||||
"slack": {"channel_id": None, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False},
|
||||
},
|
||||
"heartbeat": {
|
||||
"link": f"{integration.integration_url}heartbeat/",
|
||||
|
|
@ -394,7 +406,9 @@ def test_update_integration_name(
|
|||
"default_route": {
|
||||
"escalation_chain_id": None,
|
||||
"id": default_channel_filter.public_primary_key,
|
||||
"slack": {"channel_id": None},
|
||||
"slack": {"channel_id": None, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False},
|
||||
},
|
||||
"heartbeat": {
|
||||
"link": f"{integration.integration_url}heartbeat/",
|
||||
|
|
@ -454,7 +468,9 @@ def test_set_default_template(
|
|||
"default_route": {
|
||||
"escalation_chain_id": None,
|
||||
"id": default_channel_filter.public_primary_key,
|
||||
"slack": {"channel_id": None},
|
||||
"slack": {"channel_id": None, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False},
|
||||
},
|
||||
"heartbeat": {
|
||||
"link": f"{integration.integration_url}heartbeat/",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from django.urls import reverse
|
|||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleWeb
|
||||
|
||||
invalid_field_data_1 = {
|
||||
"frequency": None,
|
||||
|
|
@ -76,6 +76,39 @@ def test_get_on_call_shift(make_organization_and_user_with_token, make_on_call_s
|
|||
assert response.data == result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_override_on_call_shift(make_organization_and_user_with_token, make_on_call_shift, make_schedule):
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
client = APIClient()
|
||||
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
data = {
|
||||
"start": datetime.datetime.now().replace(microsecond=0),
|
||||
"duration": datetime.timedelta(seconds=7200),
|
||||
"schedule": schedule,
|
||||
}
|
||||
on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data)
|
||||
on_call_shift.users.add(user)
|
||||
|
||||
url = reverse("api-public:on_call_shifts-detail", kwargs={"pk": on_call_shift.public_primary_key})
|
||||
|
||||
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
|
||||
result = {
|
||||
"id": on_call_shift.public_primary_key,
|
||||
"team_id": None,
|
||||
"name": on_call_shift.name,
|
||||
"type": "override",
|
||||
"time_zone": None,
|
||||
"start": on_call_shift.start.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"duration": int(on_call_shift.duration.total_seconds()),
|
||||
"users": [user.public_primary_key],
|
||||
}
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_on_call_shift(make_organization_and_user_with_token):
|
||||
|
||||
|
|
@ -127,6 +160,42 @@ def test_create_on_call_shift(make_organization_and_user_with_token):
|
|||
assert response.data == result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_override_on_call_shift(make_organization_and_user_with_token):
|
||||
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-public:on_call_shifts-list")
|
||||
|
||||
start = datetime.datetime.now()
|
||||
data = {
|
||||
"team_id": None,
|
||||
"name": "test name",
|
||||
"type": "override",
|
||||
"start": start.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"duration": 10800,
|
||||
"users": [user.public_primary_key],
|
||||
}
|
||||
|
||||
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
on_call_shift = CustomOnCallShift.objects.get(public_primary_key=response.data["id"])
|
||||
|
||||
result = {
|
||||
"id": on_call_shift.public_primary_key,
|
||||
"team_id": None,
|
||||
"name": data["name"],
|
||||
"type": "override",
|
||||
"time_zone": None,
|
||||
"start": data["start"],
|
||||
"duration": data["duration"],
|
||||
"users": [user.public_primary_key],
|
||||
}
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data == result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_on_call_shift(make_organization_and_user_with_token, make_on_call_shift, make_schedule):
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ from rest_framework import status
|
|||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.alerts.models import ChannelFilter
|
||||
from apps.base.tests.messaging_backend import TestOnlyBackend
|
||||
|
||||
TEST_MESSAGING_BACKEND_FIELD = TestOnlyBackend.backend_id.lower()
|
||||
TEST_MESSAGING_BACKEND_ID = "TESTBACKENDID"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
|
@ -43,7 +47,9 @@ def test_get_route(
|
|||
"routing_regex": channel_filter.filtering_term,
|
||||
"position": channel_filter.order,
|
||||
"is_the_last_route": channel_filter.is_default,
|
||||
"slack": {"channel_id": channel_filter.slack_channel_id},
|
||||
"slack": {"channel_id": channel_filter.slack_channel_id, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False},
|
||||
}
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
|
@ -73,7 +79,9 @@ def test_get_routes_list(
|
|||
"routing_regex": channel_filter.filtering_term,
|
||||
"position": channel_filter.order,
|
||||
"is_the_last_route": channel_filter.is_default,
|
||||
"slack": {"channel_id": channel_filter.slack_channel_id},
|
||||
"slack": {"channel_id": channel_filter.slack_channel_id, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
|
@ -107,7 +115,9 @@ def test_get_routes_filter_by_integration_id(
|
|||
"routing_regex": channel_filter.filtering_term,
|
||||
"position": channel_filter.order,
|
||||
"is_the_last_route": channel_filter.is_default,
|
||||
"slack": {"channel_id": channel_filter.slack_channel_id},
|
||||
"slack": {"channel_id": channel_filter.slack_channel_id, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
|
@ -139,7 +149,9 @@ def test_create_route(
|
|||
"routing_regex": data_for_create["routing_regex"],
|
||||
"position": 0,
|
||||
"is_the_last_route": False,
|
||||
"slack": {"channel_id": None},
|
||||
"slack": {"channel_id": None, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False},
|
||||
}
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
|
@ -197,7 +209,9 @@ def test_update_route(
|
|||
"routing_regex": data_to_update["routing_regex"],
|
||||
"position": new_channel_filter.order,
|
||||
"is_the_last_route": new_channel_filter.is_default,
|
||||
"slack": {"channel_id": new_channel_filter.slack_channel_id},
|
||||
"slack": {"channel_id": new_channel_filter.slack_channel_id, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": False},
|
||||
}
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
|
@ -224,3 +238,146 @@ def test_delete_route(
|
|||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
with pytest.raises(ChannelFilter.DoesNotExist):
|
||||
new_channel_filter.refresh_from_db()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_route_with_messaging_backend(
|
||||
route_public_api_setup,
|
||||
make_slack_team_identity,
|
||||
make_slack_channel,
|
||||
):
|
||||
organization, _, token, alert_receive_channel, escalation_chain, _ = route_public_api_setup
|
||||
slack_team_identity = make_slack_team_identity()
|
||||
organization.slack_team_identity = slack_team_identity
|
||||
organization.save(update_fields=["slack_team_identity"])
|
||||
|
||||
slack_id = "TEST_SLACK_ID"
|
||||
|
||||
slack_channel = make_slack_channel(slack_team_identity, slack_id=slack_id)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-public:routes-list")
|
||||
|
||||
data_for_create = {
|
||||
"integration_id": alert_receive_channel.public_primary_key,
|
||||
"routing_regex": "testreg",
|
||||
"escalation_chain_id": escalation_chain.public_primary_key,
|
||||
"slack": {"channel_id": slack_channel.slack_id, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": True},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": TEST_MESSAGING_BACKEND_ID, "enabled": True},
|
||||
}
|
||||
response = client.post(url, format="json", HTTP_AUTHORIZATION=token, data=data_for_create)
|
||||
|
||||
expected_response = {
|
||||
"id": response.data["id"],
|
||||
"integration_id": alert_receive_channel.public_primary_key,
|
||||
"escalation_chain_id": escalation_chain.public_primary_key,
|
||||
"routing_regex": data_for_create["routing_regex"],
|
||||
"position": 0,
|
||||
"is_the_last_route": False,
|
||||
"slack": {"channel_id": slack_channel.slack_id, "enabled": True},
|
||||
"telegram": {"id": None, "enabled": True},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": TEST_MESSAGING_BACKEND_ID, "enabled": True},
|
||||
}
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json() == expected_response
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_route_with_messaging_backend(
|
||||
route_public_api_setup,
|
||||
make_channel_filter,
|
||||
make_slack_team_identity,
|
||||
make_slack_channel,
|
||||
):
|
||||
|
||||
organization, _, token, alert_receive_channel, escalation_chain, _ = route_public_api_setup
|
||||
slack_team_identity = make_slack_team_identity()
|
||||
organization.slack_team_identity = slack_team_identity
|
||||
organization.save(update_fields=["slack_team_identity"])
|
||||
|
||||
slack_id = "TEST_SLACK_ID"
|
||||
|
||||
slack_channel = make_slack_channel(slack_team_identity, slack_id=slack_id)
|
||||
|
||||
new_channel_filter = make_channel_filter(
|
||||
alert_receive_channel,
|
||||
is_default=False,
|
||||
filtering_term="testreg",
|
||||
escalation_chain=escalation_chain,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-public:routes-detail", kwargs={"pk": new_channel_filter.public_primary_key})
|
||||
data_to_update = {
|
||||
"slack": {"channel_id": slack_channel.slack_id, "enabled": False},
|
||||
"telegram": {"id": None, "enabled": True},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": TEST_MESSAGING_BACKEND_ID},
|
||||
}
|
||||
|
||||
# check if route data is different
|
||||
assert new_channel_filter.slack_channel_id != slack_channel.slack_id
|
||||
assert new_channel_filter.notify_in_slack != data_to_update["slack"]["enabled"]
|
||||
assert new_channel_filter.notify_in_telegram != data_to_update["telegram"]["enabled"]
|
||||
assert new_channel_filter.notification_backends is None
|
||||
|
||||
response = client.put(url, format="json", HTTP_AUTHORIZATION=token, data=data_to_update)
|
||||
|
||||
expected_response = {
|
||||
"id": response.data["id"],
|
||||
"integration_id": alert_receive_channel.public_primary_key,
|
||||
"escalation_chain_id": escalation_chain.public_primary_key,
|
||||
"routing_regex": new_channel_filter.filtering_term,
|
||||
"position": 0,
|
||||
"is_the_last_route": False,
|
||||
"slack": {"channel_id": slack_channel.slack_id, "enabled": False},
|
||||
"telegram": {"id": None, "enabled": True},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": TEST_MESSAGING_BACKEND_ID, "enabled": False},
|
||||
}
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == expected_response
|
||||
|
||||
new_channel_filter.refresh_from_db()
|
||||
|
||||
# check if route data is different was changed correctly
|
||||
assert new_channel_filter.slack_channel_id == slack_channel.slack_id
|
||||
assert new_channel_filter.notify_in_slack == data_to_update["slack"]["enabled"]
|
||||
assert new_channel_filter.notify_in_telegram == data_to_update["telegram"]["enabled"]
|
||||
assert new_channel_filter.notification_backends == {
|
||||
TestOnlyBackend.backend_id: {"channel": TEST_MESSAGING_BACKEND_ID}
|
||||
}
|
||||
|
||||
data_to_update = {
|
||||
"slack": {"channel_id": None, "enabled": False},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": True},
|
||||
}
|
||||
|
||||
response = client.put(url, format="json", HTTP_AUTHORIZATION=token, data=data_to_update)
|
||||
|
||||
expected_response = {
|
||||
"id": response.data["id"],
|
||||
"integration_id": alert_receive_channel.public_primary_key,
|
||||
"escalation_chain_id": escalation_chain.public_primary_key,
|
||||
"routing_regex": new_channel_filter.filtering_term,
|
||||
"position": 0,
|
||||
"is_the_last_route": False,
|
||||
"slack": {"channel_id": None, "enabled": False},
|
||||
"telegram": {"id": None, "enabled": False},
|
||||
TEST_MESSAGING_BACKEND_FIELD: {"id": None, "enabled": True},
|
||||
}
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == expected_response
|
||||
|
||||
new_channel_filter.refresh_from_db()
|
||||
|
||||
# check if route data is different was changed correctly
|
||||
assert new_channel_filter.slack_channel_id == data_to_update["slack"]["channel_id"]
|
||||
assert new_channel_filter.notify_in_slack == data_to_update["slack"]["enabled"]
|
||||
assert new_channel_filter.notify_in_telegram == data_to_update["telegram"]["enabled"]
|
||||
assert new_channel_filter.notification_backends == {TestOnlyBackend.backend_id: {"channel": None, "enabled": True}}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ from django.utils import timezone
|
|||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleICal
|
||||
from apps.schedules.models import (
|
||||
CustomOnCallShift,
|
||||
OnCallSchedule,
|
||||
OnCallScheduleCalendar,
|
||||
OnCallScheduleICal,
|
||||
OnCallScheduleWeb,
|
||||
)
|
||||
|
||||
ICAL_URL = "https://calendar.google.com/calendar/ical/amixr.io_37gttuakhrtr75ano72p69rt78%40group.calendar.google.com/private-1d00a680ba5be7426c3eb3ef1616e26d/basic.ics"
|
||||
|
||||
|
|
@ -138,6 +144,96 @@ def test_update_calendar_schedule(
|
|||
assert response.json() == result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_web_schedule(
|
||||
make_organization_and_user_with_token,
|
||||
make_schedule,
|
||||
):
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
client = APIClient()
|
||||
|
||||
slack_channel_id = "SLACKCHANNELID"
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
channel=slack_channel_id,
|
||||
)
|
||||
|
||||
url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key})
|
||||
|
||||
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
|
||||
result = {
|
||||
"id": schedule.public_primary_key,
|
||||
"team_id": None,
|
||||
"name": schedule.name,
|
||||
"type": "web",
|
||||
"time_zone": "UTC",
|
||||
"on_call_now": [],
|
||||
"shifts": [],
|
||||
"slack": {
|
||||
"channel_id": "SLACKCHANNELID",
|
||||
"user_group_id": None,
|
||||
},
|
||||
}
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_web_schedule(make_organization_and_user_with_token):
|
||||
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
client = APIClient()
|
||||
|
||||
url = reverse("api-public:schedules-list")
|
||||
|
||||
data = {
|
||||
"team_id": None,
|
||||
"name": "schedule test name",
|
||||
"time_zone": "Europe/Moscow",
|
||||
"type": "web",
|
||||
}
|
||||
|
||||
response = client.post(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json() == {"detail": "Web schedule creation is not enabled through API"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_web_schedule(
|
||||
make_organization_and_user_with_token,
|
||||
make_schedule,
|
||||
):
|
||||
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
client = APIClient()
|
||||
|
||||
slack_channel_id = "SLACKCHANNELID"
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
channel=slack_channel_id,
|
||||
)
|
||||
|
||||
url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key})
|
||||
|
||||
data = {
|
||||
"name": "RENAMED",
|
||||
"time_zone": "Europe/Moscow",
|
||||
}
|
||||
|
||||
assert schedule.name != data["name"]
|
||||
assert schedule.time_zone != data["time_zone"]
|
||||
|
||||
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json() == {"detail": "Web schedule update is not enabled through API"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_ical_url_overrides_calendar_schedule(
|
||||
make_organization_and_user_with_token,
|
||||
|
|
@ -237,6 +333,68 @@ def test_update_calendar_schedule_with_custom_event(
|
|||
assert response.json() == result
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_calendar_schedule_invalid_override(
|
||||
make_organization_and_user_with_token,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
client = APIClient()
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleCalendar,
|
||||
)
|
||||
data = {
|
||||
"start": timezone.now().replace(tzinfo=None, microsecond=0),
|
||||
"duration": timezone.timedelta(seconds=10800),
|
||||
}
|
||||
on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data)
|
||||
|
||||
url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key})
|
||||
|
||||
data = {
|
||||
"shifts": [on_call_shift.public_primary_key],
|
||||
}
|
||||
|
||||
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json() == {"detail": "Shifts of type override are not supported in this schedule"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_web_schedule_with_override(
|
||||
make_organization_and_user_with_token,
|
||||
make_schedule,
|
||||
make_on_call_shift,
|
||||
):
|
||||
|
||||
organization, user, token = make_organization_and_user_with_token()
|
||||
client = APIClient()
|
||||
|
||||
schedule = make_schedule(
|
||||
organization,
|
||||
schedule_class=OnCallScheduleWeb,
|
||||
)
|
||||
data = {
|
||||
"start": timezone.now().replace(tzinfo=None, microsecond=0),
|
||||
"duration": timezone.timedelta(seconds=10800),
|
||||
}
|
||||
on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data)
|
||||
|
||||
url = reverse("api-public:schedules-detail", kwargs={"pk": schedule.public_primary_key})
|
||||
|
||||
data = {
|
||||
"shifts": [on_call_shift.public_primary_key],
|
||||
}
|
||||
|
||||
response = client.put(url, data=data, format="json", HTTP_AUTHORIZATION=f"{token}")
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json() == {"detail": "Web schedule update is not enabled through API"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_calendar_schedule(
|
||||
make_organization_and_user_with_token,
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ from apps.public_api.custom_renderers import CalendarRenderer
|
|||
from apps.public_api.serializers import PolymorphicScheduleSerializer, PolymorphicScheduleUpdateSerializer
|
||||
from apps.public_api.throttlers.user_throttle import UserThrottle
|
||||
from apps.schedules.ical_utils import ical_export_from_schedule
|
||||
from apps.schedules.models import OnCallSchedule
|
||||
from apps.schedules.models import OnCallSchedule, OnCallScheduleWeb
|
||||
from apps.slack.tasks import update_slack_user_group_for_schedules
|
||||
from apps.user_management.organization_log_creator import OrganizationLogType, create_organization_log
|
||||
from common.api_helpers.exceptions import BadRequest
|
||||
from common.api_helpers.filters import ByTeamFilter
|
||||
from common.api_helpers.mixins import RateLimitHeadersMixin, UpdateSerializerMixin
|
||||
from common.api_helpers.paginators import FiftyPageSizePaginator
|
||||
|
|
@ -55,6 +56,9 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo
|
|||
raise NotFound
|
||||
|
||||
def perform_create(self, serializer):
|
||||
if serializer.validated_data["type"] == "web":
|
||||
raise BadRequest(detail="Web schedule creation is not enabled through API")
|
||||
|
||||
serializer.save()
|
||||
instance = serializer.instance
|
||||
|
||||
|
|
@ -67,6 +71,9 @@ class OnCallScheduleChannelView(RateLimitHeadersMixin, UpdateSerializerMixin, Mo
|
|||
create_organization_log(organization, user, OrganizationLogType.TYPE_SCHEDULE_CREATED, description)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
if isinstance(serializer.instance, OnCallScheduleWeb):
|
||||
raise BadRequest(detail="Web schedule update is not enabled through API")
|
||||
|
||||
organization = self.request.auth.organization
|
||||
user = self.request.user
|
||||
old_state = serializer.instance.repr_settings_for_client_side_logging
|
||||
|
|
|
|||
|
|
@ -75,12 +75,16 @@ ICAL_DESCRIPTION = "DESCRIPTION"
|
|||
ICAL_ATTENDEE = "ATTENDEE"
|
||||
ICAL_UID = "UID"
|
||||
RE_PRIORITY = re.compile(r"^\[L(\d)\]")
|
||||
RE_EVENT_UID_V1 = re.compile(r"amixr-([\w\d-]+)-U(\d+)-E(\d+)-S(\d+)")
|
||||
RE_EVENT_UID_V2 = re.compile(r"oncall-([\w\d-]+)-PK([\w\d]+)-U(\d+)-E(\d+)-S(\d+)")
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
# used for display schedule events on web
|
||||
def list_of_oncall_shifts_from_ical(schedule, date, user_timezone="UTC", with_empty_shifts=False, with_gaps=False):
|
||||
def list_of_oncall_shifts_from_ical(
|
||||
schedule, date, user_timezone="UTC", with_empty_shifts=False, with_gaps=False, days=1
|
||||
):
|
||||
"""
|
||||
Parse the ical file and return list of events with users
|
||||
This function is used in serializer for api schedules/events/ endpoint
|
||||
|
|
@ -106,7 +110,7 @@ def list_of_oncall_shifts_from_ical(schedule, date, user_timezone="UTC", with_em
|
|||
user_timezone_offset = timezone.datetime.now().astimezone(pytz.timezone(user_timezone)).utcoffset()
|
||||
datetime_min = timezone.datetime.combine(date, datetime.time.min) + timezone.timedelta(milliseconds=1)
|
||||
datetime_start = (datetime_min - user_timezone_offset).astimezone(pytz.UTC)
|
||||
datetime_end = datetime_start + timezone.timedelta(hours=23, minutes=59, seconds=59)
|
||||
datetime_end = datetime_start + timezone.timedelta(days=days - 1, hours=23, minutes=59, seconds=59)
|
||||
|
||||
result_datetime = []
|
||||
result_date = []
|
||||
|
|
@ -137,6 +141,7 @@ def list_of_oncall_shifts_from_ical(schedule, date, user_timezone="UTC", with_em
|
|||
"source": None,
|
||||
"calendar_type": None,
|
||||
"is_gap": True,
|
||||
"shift_pk": None,
|
||||
}
|
||||
)
|
||||
result = sorted(result_datetime, key=lambda dt: dt["start"]) + result_date
|
||||
|
|
@ -150,7 +155,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_
|
|||
result_date = []
|
||||
for event in events:
|
||||
priority = parse_priority_from_string(event.get(ICAL_SUMMARY, "[L0]"))
|
||||
source = parse_source_from_string(event.get(ICAL_UID))
|
||||
pk, source = parse_event_uid(event.get(ICAL_UID))
|
||||
users = get_users_from_ical_event(event, schedule.organization)
|
||||
# Define on-call shift out of ical event that has the actual user
|
||||
if len(users) > 0 or with_empty_shifts:
|
||||
|
|
@ -166,6 +171,7 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_
|
|||
"priority": priority,
|
||||
"source": source,
|
||||
"calendar_type": calendar_type,
|
||||
"shift_pk": pk,
|
||||
}
|
||||
)
|
||||
else:
|
||||
|
|
@ -180,13 +186,15 @@ def get_shifts_dict(calendar, calendar_type, schedule, datetime_start, datetime_
|
|||
"priority": priority,
|
||||
"source": source,
|
||||
"calendar_type": calendar_type,
|
||||
"shift_pk": pk,
|
||||
}
|
||||
)
|
||||
return result_datetime, result_date
|
||||
|
||||
|
||||
EmptyShift = namedtuple(
|
||||
"EmptyShift", ["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz"]
|
||||
"EmptyShift",
|
||||
["start", "end", "summary", "description", "attendee", "all_day", "calendar_type", "calendar_tz", "shift_pk"],
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -230,6 +238,7 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date):
|
|||
summary = event.get(ICAL_SUMMARY, "")
|
||||
description = event.get(ICAL_DESCRIPTION, "")
|
||||
attendee = event.get(ICAL_ATTENDEE, "")
|
||||
pk, _ = parse_event_uid(event.get(ICAL_UID))
|
||||
|
||||
event_hash = hash(f"{event[ICAL_UID]}{summary}{description}{attendee}")
|
||||
if event_hash in checked_events:
|
||||
|
|
@ -257,6 +266,7 @@ def list_of_empty_shifts_in_schedule(schedule, start_date, end_date):
|
|||
all_day=all_day,
|
||||
calendar_type=calendar_type,
|
||||
calendar_tz=calendar_tz,
|
||||
shift_pk=pk,
|
||||
)
|
||||
)
|
||||
empty_shifts.extend(empty_shifts_per_calendar)
|
||||
|
|
@ -330,17 +340,27 @@ def parse_priority_from_string(string):
|
|||
return priority
|
||||
|
||||
|
||||
def parse_source_from_string(string):
|
||||
CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift")
|
||||
split_string = string.split("-")
|
||||
def parse_event_uid(string):
|
||||
pk = None
|
||||
source = None
|
||||
source_verbal = None
|
||||
if len(split_string) >= 2 and split_string[0] == "amixr":
|
||||
regex = re.compile(r"^S(\d)$")
|
||||
source = re.findall(regex, split_string[-1])
|
||||
if len(source) > 0:
|
||||
source = int(source[0])
|
||||
source_verbal = CustomOnCallShift.SOURCE_CHOICES[source][1]
|
||||
return source_verbal
|
||||
|
||||
match = RE_EVENT_UID_V2.match(string)
|
||||
if match:
|
||||
_, pk, _, _, source = match.groups()
|
||||
else:
|
||||
# eventually this path would be automatically deprecated
|
||||
# once all ical representations are refreshed
|
||||
match = RE_EVENT_UID_V1.match(string)
|
||||
if match:
|
||||
_, _, _, source = match.groups()
|
||||
|
||||
if source is not None:
|
||||
source = int(source)
|
||||
CustomOnCallShift = apps.get_model("schedules", "CustomOnCallShift")
|
||||
source_verbal = CustomOnCallShift.SOURCE_CHOICES[source][1]
|
||||
|
||||
return pk, source_verbal
|
||||
|
||||
|
||||
def get_usernames_from_ical_event(event):
|
||||
|
|
|
|||
36
engine/apps/schedules/migrations/0005_auto_20220704_1947.py
Normal file
36
engine/apps/schedules/migrations/0005_auto_20220704_1947.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Generated by Django 3.2.5 on 2022-07-04 19:47
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedules', '0004_customoncallshift_until'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='OnCallScheduleWeb',
|
||||
fields=[
|
||||
('oncallschedule_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='schedules.oncallschedule')),
|
||||
('time_zone', models.CharField(default='UTC', max_length=100)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
'base_manager_name': 'objects',
|
||||
},
|
||||
bases=('schedules.oncallschedule',),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customoncallshift',
|
||||
name='schedule',
|
||||
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='custom_shifts', to='schedules.oncallschedule'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customoncallshift',
|
||||
name='type',
|
||||
field=models.IntegerField(choices=[(0, 'Single event'), (1, 'Recurrent event'), (2, 'Rolling users'), (3, 'Override')]),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,2 +1,7 @@
|
|||
from .custom_on_call_shift import CustomOnCallShift # noqa: F401
|
||||
from .on_call_schedule import OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleICal # noqa: F401
|
||||
from .on_call_schedule import ( # noqa: F401
|
||||
OnCallSchedule,
|
||||
OnCallScheduleCalendar,
|
||||
OnCallScheduleICal,
|
||||
OnCallScheduleWeb,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,18 +61,21 @@ class CustomOnCallShift(models.Model):
|
|||
TYPE_SINGLE_EVENT,
|
||||
TYPE_RECURRENT_EVENT,
|
||||
TYPE_ROLLING_USERS_EVENT,
|
||||
) = range(3)
|
||||
TYPE_OVERRIDE,
|
||||
) = range(4)
|
||||
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_SINGLE_EVENT, "Single event"),
|
||||
(TYPE_RECURRENT_EVENT, "Recurrent event"),
|
||||
(TYPE_ROLLING_USERS_EVENT, "Rolling users"),
|
||||
(TYPE_OVERRIDE, "Override"),
|
||||
)
|
||||
|
||||
PUBLIC_TYPE_CHOICES_MAP = {
|
||||
TYPE_SINGLE_EVENT: "single_event",
|
||||
TYPE_RECURRENT_EVENT: "recurrent_event",
|
||||
TYPE_ROLLING_USERS_EVENT: "rolling_users",
|
||||
TYPE_OVERRIDE: "override",
|
||||
}
|
||||
|
||||
(MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY) = range(7)
|
||||
|
|
@ -128,6 +131,13 @@ class CustomOnCallShift(models.Model):
|
|||
null=True,
|
||||
default=None,
|
||||
)
|
||||
schedule = models.ForeignKey(
|
||||
"schedules.OnCallSchedule",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="custom_shifts",
|
||||
null=True,
|
||||
default=None,
|
||||
)
|
||||
name = models.CharField(max_length=200)
|
||||
time_zone = models.CharField(max_length=100, null=True, default=None)
|
||||
source = models.IntegerField(choices=SOURCE_CHOICES, default=SOURCE_API)
|
||||
|
|
@ -136,7 +146,7 @@ class CustomOnCallShift(models.Model):
|
|||
start_rotation_from_user_index = models.PositiveIntegerField(null=True, default=None)
|
||||
|
||||
uuid = models.UUIDField(default=uuid4) # event uuid
|
||||
type = models.IntegerField(choices=TYPE_CHOICES) # "rolling_users", "recurrent_event", "single_event"
|
||||
type = models.IntegerField(choices=TYPE_CHOICES) # "rolling_users", "recurrent_event", "single_event", "override"
|
||||
|
||||
start = models.DateTimeField() # event start datetime
|
||||
duration = models.DurationField() # duration in seconds
|
||||
|
|
@ -191,7 +201,7 @@ class CustomOnCallShift(models.Model):
|
|||
f"source: {self.get_source_display()}, type: {self.get_type_display()}, users: {users_verbal}, "
|
||||
f"start: {self.start.isoformat()}, duration: {self.duration}, priority level: {self.priority_level}"
|
||||
)
|
||||
if self.type != CustomOnCallShift.TYPE_SINGLE_EVENT:
|
||||
if self.type not in (CustomOnCallShift.TYPE_SINGLE_EVENT, CustomOnCallShift.TYPE_OVERRIDE):
|
||||
result += (
|
||||
f", frequency: {self.get_frequency_display()}, interval: {self.interval}, "
|
||||
f"week start: {self.week_start}, by day: {self.by_day}, by month: {self.by_month}, "
|
||||
|
|
@ -220,7 +230,7 @@ class CustomOnCallShift(models.Model):
|
|||
def generate_ical(self, user, start, user_counter, counter=1, time_zone="UTC"):
|
||||
# create event for each user in a list because we can't parse multiple users from ical summary
|
||||
event = Event()
|
||||
event["uid"] = f"amixr-{self.uuid}-U{user_counter}-E{counter}-S{self.source}"
|
||||
event["uid"] = f"oncall-{self.uuid}-PK{self.public_primary_key}-U{user_counter}-E{counter}-S{self.source}"
|
||||
event.add("summary", self.get_summary_with_user_for_ical(user))
|
||||
event.add("dtstart", self.convert_dt_to_schedule_timezone(start, time_zone))
|
||||
event.add("dtend", self.convert_dt_to_schedule_timezone(start + self.duration, time_zone))
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from apps.schedules.ical_utils import (
|
|||
list_of_gaps_in_schedule,
|
||||
list_users_to_notify_from_ical,
|
||||
)
|
||||
from apps.schedules.models import CustomOnCallShift
|
||||
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
|
||||
|
||||
|
||||
|
|
@ -212,10 +213,14 @@ class OnCallSchedule(PolymorphicModel):
|
|||
raise NotImplementedError
|
||||
|
||||
def _drop_primary_ical_file(self):
|
||||
raise NotImplementedError
|
||||
self.prev_ical_file_primary = self.cached_ical_file_primary
|
||||
self.cached_ical_file_primary = None
|
||||
self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"])
|
||||
|
||||
def _drop_overrides_ical_file(self):
|
||||
raise NotImplementedError
|
||||
self.prev_ical_file_overrides = self.cached_ical_file_overrides
|
||||
self.cached_ical_file_overrides = None
|
||||
self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"])
|
||||
|
||||
|
||||
class OnCallScheduleICal(OnCallSchedule):
|
||||
|
|
@ -254,26 +259,6 @@ class OnCallScheduleICal(OnCallSchedule):
|
|||
cached_ical_file = self.cached_ical_file_overrides
|
||||
return cached_ical_file
|
||||
|
||||
def _drop_primary_ical_file(self):
|
||||
self.prev_ical_file_primary = self.cached_ical_file_primary
|
||||
self.cached_ical_file_primary = None
|
||||
self.save(
|
||||
update_fields=[
|
||||
"cached_ical_file_primary",
|
||||
"prev_ical_file_primary",
|
||||
]
|
||||
)
|
||||
|
||||
def _drop_overrides_ical_file(self):
|
||||
self.prev_ical_file_overrides = self.cached_ical_file_overrides
|
||||
self.cached_ical_file_overrides = None
|
||||
self.save(
|
||||
update_fields=[
|
||||
"cached_ical_file_overrides",
|
||||
"prev_ical_file_overrides",
|
||||
]
|
||||
)
|
||||
|
||||
def _refresh_primary_ical_file(self):
|
||||
self.prev_ical_file_primary = self.cached_ical_file_primary
|
||||
if self.ical_url_primary is not None:
|
||||
|
|
@ -350,26 +335,6 @@ class OnCallScheduleCalendar(OnCallSchedule):
|
|||
)
|
||||
self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides", "ical_file_error_overrides"])
|
||||
|
||||
def _drop_primary_ical_file(self):
|
||||
self.prev_ical_file_primary = self.cached_ical_file_primary
|
||||
self.cached_ical_file_primary = None
|
||||
self.save(
|
||||
update_fields=[
|
||||
"cached_ical_file_primary",
|
||||
"prev_ical_file_primary",
|
||||
]
|
||||
)
|
||||
|
||||
def _drop_overrides_ical_file(self):
|
||||
self.prev_ical_file_overrides = self.cached_ical_file_overrides
|
||||
self.cached_ical_file_overrides = None
|
||||
self.save(
|
||||
update_fields=[
|
||||
"cached_ical_file_overrides",
|
||||
"prev_ical_file_overrides",
|
||||
]
|
||||
)
|
||||
|
||||
def _generate_ical_file_primary(self):
|
||||
"""
|
||||
Generate iCal events file from custom on-call shifts (created via API)
|
||||
|
|
@ -394,3 +359,58 @@ class OnCallScheduleCalendar(OnCallSchedule):
|
|||
result = super().repr_settings_for_client_side_logging
|
||||
result += f", overrides calendar url: {self.ical_url_overrides}"
|
||||
return result
|
||||
|
||||
|
||||
class OnCallScheduleWeb(OnCallSchedule):
|
||||
time_zone = models.CharField(max_length=100, default="UTC")
|
||||
|
||||
def _generate_ical_file_from_shifts(self, qs):
|
||||
"""Generate iCal events file from custom on-call shifts."""
|
||||
ical = None
|
||||
if qs.exists():
|
||||
end_line = "END:VCALENDAR"
|
||||
calendar = Calendar()
|
||||
calendar.add("prodid", "-//web schedule//oncall//")
|
||||
calendar.add("version", "2.0")
|
||||
calendar.add("method", "PUBLISH")
|
||||
ical_file = calendar.to_ical().decode()
|
||||
ical = ical_file.replace(end_line, "").strip()
|
||||
ical = f"{ical}\r\n"
|
||||
for event in qs.all():
|
||||
ical += event.convert_to_ical(self.time_zone)
|
||||
ical += f"{end_line}\r\n"
|
||||
return ical
|
||||
|
||||
def _generate_ical_file_primary(self):
|
||||
qs = self.custom_shifts.exclude(type=CustomOnCallShift.TYPE_OVERRIDE)
|
||||
return self._generate_ical_file_from_shifts(qs)
|
||||
|
||||
def _generate_ical_file_overrides(self):
|
||||
qs = self.custom_shifts.filter(type=CustomOnCallShift.TYPE_OVERRIDE)
|
||||
return self._generate_ical_file_from_shifts(qs)
|
||||
|
||||
@cached_property
|
||||
def _ical_file_primary(self):
|
||||
"""Return cached ical file with iCal events from custom on-call shifts."""
|
||||
if self.cached_ical_file_primary is None:
|
||||
self.cached_ical_file_primary = self._generate_ical_file_primary()
|
||||
self.save(update_fields=["cached_ical_file_primary"])
|
||||
return self.cached_ical_file_primary
|
||||
|
||||
def _refresh_primary_ical_file(self):
|
||||
self.prev_ical_file_primary = self.cached_ical_file_primary
|
||||
self.cached_ical_file_primary = self._generate_ical_file_primary()
|
||||
self.save(update_fields=["cached_ical_file_primary", "prev_ical_file_primary"])
|
||||
|
||||
@cached_property
|
||||
def _ical_file_overrides(self):
|
||||
"""Return cached ical file with iCal events from custom on-call overrides shifts."""
|
||||
if self.cached_ical_file_overrides is None:
|
||||
self.cached_ical_file_overrides = self._generate_ical_file_overrides()
|
||||
self.save(update_fields=["cached_ical_file_overrides"])
|
||||
return self.cached_ical_file_overrides
|
||||
|
||||
def _refresh_overrides_ical_file(self):
|
||||
self.prev_ical_file_overrides = self.cached_ical_file_overrides
|
||||
self.cached_ical_file_overrides = self._generate_ical_file_overrides()
|
||||
self.save(update_fields=["cached_ical_file_overrides", "prev_ical_file_overrides"])
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import factory
|
||||
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleICal
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar, OnCallScheduleICal, OnCallScheduleWeb
|
||||
from common.utils import UniqueFaker
|
||||
|
||||
|
||||
|
|
@ -25,6 +25,11 @@ class OnCallScheduleCalendarFactory(OnCallScheduleFactory):
|
|||
model = OnCallScheduleCalendar
|
||||
|
||||
|
||||
class OnCallScheduleWebFactory(OnCallScheduleFactory):
|
||||
class Meta:
|
||||
model = OnCallScheduleWeb
|
||||
|
||||
|
||||
class CustomOnCallShiftFactory(factory.DjangoModelFactory):
|
||||
name = UniqueFaker("sentence", nb_words=2)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import pytest
|
|||
from django.utils import timezone
|
||||
|
||||
from apps.schedules.ical_utils import list_users_to_notify_from_ical
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallSchedule, OnCallScheduleCalendar, OnCallScheduleWeb
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
|
@ -32,6 +32,29 @@ def test_get_on_call_users_from_single_event(make_organization_and_user, make_on
|
|||
assert user in users_on_call
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_on_call_users_from_web_schedule_override(make_organization_and_user, make_on_call_shift, make_schedule):
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
date = timezone.now().replace(tzinfo=None, microsecond=0)
|
||||
|
||||
data = {
|
||||
"start": date,
|
||||
"duration": timezone.timedelta(seconds=10800),
|
||||
"schedule": schedule,
|
||||
}
|
||||
|
||||
on_call_shift = make_on_call_shift(organization=organization, shift_type=CustomOnCallShift.TYPE_OVERRIDE, **data)
|
||||
on_call_shift.users.add(user)
|
||||
|
||||
# user is on-call
|
||||
date = date + timezone.timedelta(minutes=5)
|
||||
users_on_call = list_users_to_notify_from_ical(schedule, date)
|
||||
assert len(users_on_call) == 1
|
||||
assert user in users_on_call
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_on_call_users_from_recurrent_event(make_organization_and_user, make_on_call_shift, make_schedule):
|
||||
organization, user = make_organization_and_user()
|
||||
|
|
@ -72,6 +95,47 @@ def test_get_on_call_users_from_recurrent_event(make_organization_and_user, make
|
|||
assert user in users_on_call
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_on_call_users_from_web_schedule_recurrent_event(
|
||||
make_organization_and_user, make_on_call_shift, make_schedule
|
||||
):
|
||||
organization, user = make_organization_and_user()
|
||||
|
||||
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
|
||||
date = timezone.now().replace(tzinfo=None, microsecond=0)
|
||||
|
||||
data = {
|
||||
"priority_level": 1,
|
||||
"start": date,
|
||||
"duration": timezone.timedelta(seconds=10800),
|
||||
"frequency": CustomOnCallShift.FREQUENCY_DAILY,
|
||||
"interval": 2,
|
||||
"schedule": schedule,
|
||||
}
|
||||
|
||||
on_call_shift = make_on_call_shift(
|
||||
organization=organization, shift_type=CustomOnCallShift.TYPE_RECURRENT_EVENT, **data
|
||||
)
|
||||
on_call_shift.users.add(user)
|
||||
|
||||
# user is on-call
|
||||
date = date + timezone.timedelta(minutes=5)
|
||||
users_on_call = list_users_to_notify_from_ical(schedule, date)
|
||||
assert len(users_on_call) == 1
|
||||
assert user in users_on_call
|
||||
|
||||
# user is not on-call according to event recurrence rules (interval = 2)
|
||||
date = date + timezone.timedelta(days=1)
|
||||
users_on_call = list_users_to_notify_from_ical(schedule, date)
|
||||
assert len(users_on_call) == 0
|
||||
|
||||
# user is on-call again
|
||||
date = date + timezone.timedelta(days=1)
|
||||
users_on_call = list_users_to_notify_from_ical(schedule, date)
|
||||
assert len(users_on_call) == 1
|
||||
assert user in users_on_call
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_on_call_users_from_rolling_users_event(
|
||||
make_organization_and_user, make_user_for_organization, make_on_call_shift, make_schedule
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.schedules.ical_utils import list_users_to_notify_from_ical, users_in_ical
|
||||
from apps.schedules.ical_utils import list_users_to_notify_from_ical, parse_event_uid, users_in_ical
|
||||
from apps.schedules.models import CustomOnCallShift, OnCallScheduleCalendar
|
||||
from common.constants.role import Role
|
||||
|
||||
|
|
@ -58,3 +60,20 @@ def test_list_users_to_notify_from_ical_viewers_inclusion(
|
|||
else:
|
||||
assert len(users_on_call) == 1
|
||||
assert set(users_on_call) == {user}
|
||||
|
||||
|
||||
def test_parse_event_uid_v1():
|
||||
uuid = uuid4()
|
||||
event_uid = f"amixr-{uuid}-U1-E2-S1"
|
||||
pk, source = parse_event_uid(event_uid)
|
||||
assert pk is None
|
||||
assert source == "api"
|
||||
|
||||
|
||||
def test_parse_event_uid_v2():
|
||||
uuid = uuid4()
|
||||
pk_value = "OABCDEF12345"
|
||||
event_uid = f"oncall-{uuid}-PK{pk_value}-U3-E1-S2"
|
||||
pk, source = parse_event_uid(event_uid)
|
||||
assert pk == pk_value
|
||||
assert source == "slack"
|
||||
|
|
|
|||
42
engine/apps/slack/scenarios/invited_to_channel.py
Normal file
42
engine/apps/slack/scenarios/invited_to_channel.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import logging
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.slack.scenarios import scenario_step
|
||||
from apps.slack.slack_client import SlackClientWithErrorHandling
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class InvitedToChannelStep(scenario_step.ScenarioStep):
|
||||
tags = [
|
||||
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
|
||||
]
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
if payload["event"]["user"] == slack_team_identity.bot_user_id:
|
||||
channel_id = payload["event"]["channel"]
|
||||
slack_client = SlackClientWithErrorHandling(slack_team_identity.bot_access_token)
|
||||
channel = slack_client.api_call("conversations.info", channel=channel_id)["channel"]
|
||||
|
||||
slack_team_identity.cached_channels.update_or_create(
|
||||
slack_id=channel["id"],
|
||||
defaults={
|
||||
"name": channel["name"],
|
||||
"is_archived": channel["is_archived"],
|
||||
"is_shared": channel["is_shared"],
|
||||
"last_populated": timezone.now().date(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info("Other user was invited to a channel with a bot.")
|
||||
|
||||
|
||||
STEPS_ROUTING = [
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK,
|
||||
"event_type": scenario_step.EVENT_TYPE_MEMBER_JOINED_CHANNEL,
|
||||
"step": InvitedToChannelStep,
|
||||
},
|
||||
]
|
||||
687
engine/apps/slack/scenarios/manual_incident.py
Normal file
687
engine/apps/slack/scenarios/manual_incident.py
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
import json
|
||||
from uuid import uuid4
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
|
||||
from apps.alerts.models import AlertReceiveChannel
|
||||
from apps.slack.scenarios import scenario_step
|
||||
from apps.slack.slack_client.exceptions import SlackAPIException
|
||||
|
||||
MANUAL_INCIDENT_TEAM_SELECT_ID = "manual_incident_team_select"
|
||||
MANUAL_INCIDENT_ORG_SELECT_ID = "manual_incident_org_select"
|
||||
MANUAL_INCIDENT_ROUTE_SELECT_ID = "manual_incident_route_select"
|
||||
MANUAL_INCIDENT_TITLE_INPUT_ID = "manual_incident_title_input"
|
||||
MANUAL_INCIDENT_MESSAGE_INPUT_ID = "manual_incident_message_input"
|
||||
|
||||
DEFAULT_TEAM_VALUE = "default_team"
|
||||
|
||||
|
||||
class StartCreateIncidentFromMessage(scenario_step.ScenarioStep):
|
||||
"""
|
||||
StartCreateIncidentFromMessage triggers creation of a manual incident from the slack message via submenu
|
||||
"""
|
||||
|
||||
callback_id = [
|
||||
"incident_create",
|
||||
"incident_create_staging",
|
||||
"incident_create_develop",
|
||||
]
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
input_id_prefix = _generate_input_id_prefix()
|
||||
|
||||
channel_id = payload["channel"]["id"]
|
||||
try:
|
||||
image_url = payload["message"]["files"][0]["permalink"]
|
||||
except KeyError:
|
||||
image_url = None
|
||||
private_metadata = {
|
||||
"channel_id": channel_id,
|
||||
"image_url": image_url,
|
||||
"message": {
|
||||
"user": payload["message"].get("user"),
|
||||
"text": payload["message"].get("text"),
|
||||
"ts": payload["message"].get("ts"),
|
||||
},
|
||||
"input_id_prefix": input_id_prefix,
|
||||
"with_title_and_message_inputs": False,
|
||||
"submit_routing_uid": FinishCreateIncidentFromMessage.routing_uid(),
|
||||
}
|
||||
|
||||
blocks = _get_manual_incident_initial_form_fields(
|
||||
slack_team_identity, slack_user_identity, input_id_prefix, payload
|
||||
)
|
||||
view = _get_manual_incident_form_view(
|
||||
FinishCreateIncidentFromMessage.routing_uid(), blocks, json.dumps(private_metadata)
|
||||
)
|
||||
self._slack_client.api_call(
|
||||
"views.open",
|
||||
trigger_id=payload["trigger_id"],
|
||||
view=view,
|
||||
)
|
||||
|
||||
|
||||
class FinishCreateIncidentFromMessage(scenario_step.ScenarioStep):
|
||||
"""
|
||||
FinishCreateIncidentFromMessage creates a manual incident from the slack message via submenu
|
||||
"""
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
Alert = apps.get_model("alerts", "Alert")
|
||||
|
||||
private_metadata = json.loads(payload["view"]["private_metadata"])
|
||||
|
||||
channel_id = private_metadata["channel_id"]
|
||||
|
||||
input_id_prefix = private_metadata["input_id_prefix"]
|
||||
selected_organization = _get_selected_org_from_payload(payload, input_id_prefix)
|
||||
selected_team = _get_selected_team_from_payload(payload, input_id_prefix)
|
||||
selected_route = _get_selected_route_from_payload(payload, input_id_prefix)
|
||||
|
||||
user = slack_user_identity.get_user(selected_organization)
|
||||
alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration(
|
||||
organization=selected_organization,
|
||||
team=selected_team,
|
||||
integration=AlertReceiveChannel.INTEGRATION_MANUAL,
|
||||
deleted_at=None,
|
||||
defaults={
|
||||
"author": user,
|
||||
"verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)",
|
||||
},
|
||||
)
|
||||
|
||||
author_username = slack_user_identity.slack_verbal
|
||||
try:
|
||||
permalink = self._slack_client.api_call(
|
||||
"chat.getPermalink",
|
||||
channel=private_metadata["channel_id"],
|
||||
message_ts=private_metadata["message"]["ts"],
|
||||
)
|
||||
permalink = permalink.get("permalink", None)
|
||||
except SlackAPIException:
|
||||
permalink = None
|
||||
title = "Message from {}".format(author_username)
|
||||
message = private_metadata["message"]["text"]
|
||||
|
||||
# Deprecated, use custom oncall property instead.
|
||||
# update private metadata in payload to use it in alert rendering
|
||||
payload["view"]["private_metadata"] = private_metadata
|
||||
payload["view"]["private_metadata"]["author_username"] = author_username
|
||||
# Custom oncall property in payload to simplify rendering
|
||||
payload["oncall"] = {}
|
||||
payload["oncall"]["title"] = title
|
||||
payload["oncall"]["message"] = message
|
||||
payload["oncall"]["author_username"] = author_username
|
||||
payload["oncall"]["permalink"] = permalink
|
||||
Alert.create(
|
||||
title=title,
|
||||
message=message,
|
||||
image_url=private_metadata["image_url"],
|
||||
# Link to the slack message is not here bc it redirects to browser
|
||||
link_to_upstream_details=None,
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
raw_request_data=payload,
|
||||
integration_unique_data={"created_by": user.get_user_verbal_for_team_for_slack()},
|
||||
force_route_id=selected_route.pk,
|
||||
)
|
||||
|
||||
try:
|
||||
self._slack_client.api_call(
|
||||
"chat.postEphemeral",
|
||||
channel=channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
text=":white_check_mark: Alert successfully submitted",
|
||||
)
|
||||
except SlackAPIException as e:
|
||||
if e.response["error"] == "channel_not_found" or e.response["error"] == "user_not_in_channel":
|
||||
self._slack_client.api_call(
|
||||
"chat.postEphemeral",
|
||||
channel=slack_user_identity.im_channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
text=":white_check_mark: Alert successfully submitted",
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
|
||||
class StartCreateIncidentFromSlashCommand(scenario_step.ScenarioStep):
|
||||
"""
|
||||
StartCreateIncidentFromSlashCommand triggers creation of a manual incident from the slack message via slash command
|
||||
"""
|
||||
|
||||
command_name = [settings.SLACK_SLASH_COMMAND_NAME]
|
||||
TITLE_INPUT_BLOCK_ID = "TITLE_INPUT"
|
||||
MESSAGE_INPUT_BLOCK_ID = "MESSAGE_INPUT"
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
input_id_prefix = _generate_input_id_prefix()
|
||||
|
||||
try:
|
||||
channel_id = payload["event"]["channel"]
|
||||
except KeyError:
|
||||
channel_id = payload["channel_id"]
|
||||
|
||||
private_metadata = {
|
||||
"channel_id": channel_id,
|
||||
"input_id_prefix": input_id_prefix,
|
||||
"with_title_and_message_inputs": True,
|
||||
"submit_routing_uid": FinishCreateIncidentFromSlashCommand.routing_uid(),
|
||||
}
|
||||
|
||||
blocks = _get_manual_incident_initial_form_fields(
|
||||
slack_team_identity, slack_user_identity, input_id_prefix, payload, with_title_and_message_inputs=True
|
||||
)
|
||||
view = _get_manual_incident_form_view(
|
||||
FinishCreateIncidentFromSlashCommand.routing_uid(), blocks, json.dumps(private_metadata)
|
||||
)
|
||||
|
||||
self._slack_client.api_call(
|
||||
"views.open",
|
||||
trigger_id=payload["trigger_id"],
|
||||
view=view,
|
||||
)
|
||||
|
||||
|
||||
class FinishCreateIncidentFromSlashCommand(scenario_step.ScenarioStep):
|
||||
"""
|
||||
FinishCreateIncidentFromSlashCommand creates a manual incident from the slack message via slash message
|
||||
"""
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
Alert = apps.get_model("alerts", "Alert")
|
||||
|
||||
title = _get_title_from_payload(payload)
|
||||
message = _get_message_from_payload(payload)
|
||||
|
||||
private_metadata = json.loads(payload["view"]["private_metadata"])
|
||||
|
||||
channel_id = private_metadata["channel_id"]
|
||||
|
||||
input_id_prefix = private_metadata["input_id_prefix"]
|
||||
selected_organization = _get_selected_org_from_payload(payload, input_id_prefix)
|
||||
selected_team = _get_selected_team_from_payload(payload, input_id_prefix)
|
||||
selected_route = _get_selected_route_from_payload(payload, input_id_prefix)
|
||||
|
||||
user = slack_user_identity.get_user(selected_organization)
|
||||
alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration(
|
||||
organization=selected_organization,
|
||||
team=selected_team,
|
||||
integration=AlertReceiveChannel.INTEGRATION_MANUAL,
|
||||
deleted_at=None,
|
||||
defaults={
|
||||
"author": user,
|
||||
"verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)",
|
||||
},
|
||||
)
|
||||
|
||||
author_username = slack_user_identity.slack_verbal
|
||||
|
||||
try:
|
||||
self._slack_client.api_call(
|
||||
"chat.postEphemeral",
|
||||
channel=channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
text=":white_check_mark: Alert *{}* successfully submitted".format(title),
|
||||
)
|
||||
except SlackAPIException as e:
|
||||
if e.response["error"] == "channel_not_found":
|
||||
self._slack_client.api_call(
|
||||
"chat.postEphemeral",
|
||||
channel=slack_user_identity.im_channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
text=":white_check_mark: Alert *{}* successfully submitted".format(title),
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
# Deprecated, use custom oncall property instead.
|
||||
# Update private metadata to use it in rendering:
|
||||
payload["view"]["private_metadata"] = private_metadata
|
||||
# Custom oncall property to simplify rendering
|
||||
payload["oncall"] = {}
|
||||
payload["oncall"]["title"] = title
|
||||
payload["oncall"]["message"] = message
|
||||
payload["oncall"]["author_username"] = author_username
|
||||
payload["oncall"]["permalink"] = None
|
||||
|
||||
Alert.create(
|
||||
title=title,
|
||||
message=message,
|
||||
image_url=None,
|
||||
link_to_upstream_details=None,
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
raw_request_data=payload,
|
||||
integration_unique_data={
|
||||
"created_by": author_username,
|
||||
},
|
||||
force_route_id=selected_route.pk,
|
||||
)
|
||||
|
||||
|
||||
# OnChange steps responsible for rerendering manual incident creation form on change values in selects.
|
||||
# They are works both with incident creation from submenu and slack command.
|
||||
|
||||
|
||||
class OnOrgChange(scenario_step.ScenarioStep):
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
private_metadata = json.loads(payload["view"]["private_metadata"])
|
||||
with_title_and_message_inputs = private_metadata.get("with_title_and_message_inputs", False)
|
||||
submit_routing_uid = private_metadata.get("submit_routing_uid")
|
||||
old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata(
|
||||
private_metadata
|
||||
)
|
||||
|
||||
selected_organization = _get_selected_org_from_payload(payload, old_input_id_prefix)
|
||||
# Set selected team to default because org is changed.
|
||||
selected_team = None
|
||||
|
||||
user = slack_user_identity.get_user(selected_organization)
|
||||
manual_integration = AlertReceiveChannel.get_or_create_manual_integration(
|
||||
organization=selected_organization,
|
||||
team=selected_team,
|
||||
integration=AlertReceiveChannel.INTEGRATION_MANUAL,
|
||||
deleted_at=None,
|
||||
defaults={
|
||||
"author": user,
|
||||
"verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)",
|
||||
},
|
||||
)
|
||||
selected_route = manual_integration.default_channel_filter
|
||||
|
||||
organization_select = _get_organization_select(
|
||||
slack_team_identity, slack_user_identity, selected_organization, new_input_id_prefix
|
||||
)
|
||||
team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix)
|
||||
route_select = _get_route_select(manual_integration, selected_route, new_input_id_prefix)
|
||||
|
||||
blocks = [organization_select, team_select, route_select]
|
||||
if with_title_and_message_inputs:
|
||||
blocks.extend([_get_title_input(payload), _get_message_input(payload)])
|
||||
view = _get_manual_incident_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata))
|
||||
self._slack_client.api_call(
|
||||
"views.update",
|
||||
trigger_id=payload["trigger_id"],
|
||||
view=view,
|
||||
view_id=payload["view"]["id"],
|
||||
)
|
||||
|
||||
|
||||
class OnTeamChange(scenario_step.ScenarioStep):
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
private_metadata = json.loads(payload["view"]["private_metadata"])
|
||||
with_title_and_message_inputs = private_metadata.get("with_title_and_message_inputs", False)
|
||||
submit_routing_uid = private_metadata.get("submit_routing_uid")
|
||||
old_input_id_prefix, new_input_id_prefix, new_private_metadata = _get_and_change_input_id_prefix_from_metadata(
|
||||
private_metadata
|
||||
)
|
||||
|
||||
selected_organization = _get_selected_org_from_payload(payload, old_input_id_prefix)
|
||||
selected_team = _get_selected_team_from_payload(payload, old_input_id_prefix)
|
||||
|
||||
user = slack_user_identity.get_user(selected_organization)
|
||||
manual_integration = AlertReceiveChannel.get_or_create_manual_integration(
|
||||
organization=selected_organization,
|
||||
team=selected_team,
|
||||
integration=AlertReceiveChannel.INTEGRATION_MANUAL,
|
||||
deleted_at=None,
|
||||
defaults={
|
||||
"author": user,
|
||||
"verbal_name": f"Manual incidents ({selected_team.name if selected_team else 'General'} team)",
|
||||
},
|
||||
)
|
||||
initial_route = manual_integration.default_channel_filter
|
||||
|
||||
organization_select = _get_organization_select(
|
||||
slack_team_identity, slack_user_identity, selected_organization, new_input_id_prefix
|
||||
)
|
||||
team_select = _get_team_select(slack_user_identity, selected_organization, selected_team, new_input_id_prefix)
|
||||
route_select = _get_route_select(manual_integration, initial_route, new_input_id_prefix)
|
||||
|
||||
blocks = [organization_select, team_select, route_select]
|
||||
if with_title_and_message_inputs:
|
||||
blocks.extend([_get_title_input(payload), _get_message_input(payload)])
|
||||
view = _get_manual_incident_form_view(submit_routing_uid, blocks, json.dumps(new_private_metadata))
|
||||
self._slack_client.api_call(
|
||||
"views.update",
|
||||
trigger_id=payload["trigger_id"],
|
||||
view=view,
|
||||
view_id=payload["view"]["id"],
|
||||
)
|
||||
|
||||
|
||||
class OnRouteChange(scenario_step.ScenarioStep):
|
||||
"""
|
||||
OnRouteChange is just a plug to handle change of value on route select
|
||||
"""
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
pass
|
||||
|
||||
|
||||
def _get_manual_incident_form_view(routing_uid, blocks, private_metatada):
|
||||
view = {
|
||||
"type": "modal",
|
||||
"callback_id": routing_uid,
|
||||
"title": {
|
||||
"type": "plain_text",
|
||||
"text": "Create an Incident",
|
||||
},
|
||||
"close": {
|
||||
"type": "plain_text",
|
||||
"text": "Cancel",
|
||||
"emoji": True,
|
||||
},
|
||||
"submit": {
|
||||
"type": "plain_text",
|
||||
"text": "Submit",
|
||||
},
|
||||
"blocks": blocks,
|
||||
"private_metadata": private_metatada,
|
||||
}
|
||||
|
||||
return view
|
||||
|
||||
|
||||
def _get_manual_incident_initial_form_fields(
|
||||
slack_team_identity, slack_user_identity, input_id_prefix, payload, with_title_and_message_inputs=False
|
||||
):
|
||||
initial_organization = (
|
||||
slack_team_identity.organizations.filter(users__slack_user_identity=slack_user_identity)
|
||||
.order_by("pk")
|
||||
.distinct()
|
||||
.first()
|
||||
)
|
||||
|
||||
organization_select = _get_organization_select(
|
||||
slack_team_identity, slack_user_identity, initial_organization, input_id_prefix
|
||||
)
|
||||
|
||||
initial_team = None # means default team
|
||||
team_select = _get_team_select(slack_user_identity, initial_organization, initial_team, input_id_prefix)
|
||||
|
||||
user = slack_user_identity.get_user(initial_organization)
|
||||
manual_integration = AlertReceiveChannel.get_or_create_manual_integration(
|
||||
organization=initial_organization,
|
||||
team=initial_team,
|
||||
integration=AlertReceiveChannel.INTEGRATION_MANUAL,
|
||||
deleted_at=None,
|
||||
defaults={
|
||||
"author": user,
|
||||
"verbal_name": f"Manual incidents ({initial_team.name if initial_team else 'General'} team)",
|
||||
},
|
||||
)
|
||||
|
||||
initial_route = manual_integration.default_channel_filter
|
||||
route_select = _get_route_select(manual_integration, initial_route, input_id_prefix)
|
||||
blocks = [organization_select, team_select, route_select]
|
||||
if with_title_and_message_inputs:
|
||||
title_input = _get_title_input(payload)
|
||||
message_input = _get_message_input(payload)
|
||||
blocks.append(title_input)
|
||||
blocks.append(message_input)
|
||||
return blocks
|
||||
|
||||
|
||||
def _get_organization_select(slack_team_identity, slack_user_identity, value, input_id_prefix):
|
||||
organizations = slack_team_identity.organizations.filter(
|
||||
users__slack_user_identity=slack_user_identity,
|
||||
).distinct()
|
||||
organizations_options = []
|
||||
initial_option_idx = 0
|
||||
for idx, org in enumerate(organizations):
|
||||
if org == value:
|
||||
initial_option_idx = idx
|
||||
organizations_options.append(
|
||||
{
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"{org.org_title}",
|
||||
"emoji": True,
|
||||
},
|
||||
"value": f"{org.pk}",
|
||||
}
|
||||
)
|
||||
|
||||
organization_select = {
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": "Select an organization"},
|
||||
"block_id": input_id_prefix + MANUAL_INCIDENT_ORG_SELECT_ID,
|
||||
"accessory": {
|
||||
"type": "static_select",
|
||||
"placeholder": {"type": "plain_text", "text": "Select an organization", "emoji": True},
|
||||
"options": organizations_options,
|
||||
"action_id": OnOrgChange.routing_uid(),
|
||||
"initial_option": organizations_options[initial_option_idx],
|
||||
},
|
||||
}
|
||||
|
||||
return organization_select
|
||||
|
||||
|
||||
def _get_selected_org_from_payload(payload, input_id_prefix):
|
||||
Organization = apps.get_model("user_management", "Organization")
|
||||
selected_org_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_ORG_SELECT_ID][
|
||||
OnOrgChange.routing_uid()
|
||||
]["selected_option"]["value"]
|
||||
org = Organization.objects.filter(pk=selected_org_id).first()
|
||||
return org
|
||||
|
||||
|
||||
def _get_team_select(slack_user_identity, organization, value, input_id_prefix):
|
||||
teams = organization.teams.filter(
|
||||
users__slack_user_identity=slack_user_identity,
|
||||
).distinct()
|
||||
team_options = []
|
||||
# Adding pseudo option for default team
|
||||
initial_option_idx = 0
|
||||
team_options.append(
|
||||
{
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"General",
|
||||
"emoji": True,
|
||||
},
|
||||
"value": DEFAULT_TEAM_VALUE,
|
||||
}
|
||||
)
|
||||
for idx, team in enumerate(teams):
|
||||
if team == value:
|
||||
# Add 1 because default team option was added before cycle, so option indicies are shifted
|
||||
initial_option_idx = idx + 1
|
||||
team_options.append(
|
||||
{
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"{team.name}",
|
||||
"emoji": True,
|
||||
},
|
||||
"value": f"{team.pk}",
|
||||
}
|
||||
)
|
||||
|
||||
team_select = {
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": "Select a team"},
|
||||
"block_id": input_id_prefix + MANUAL_INCIDENT_TEAM_SELECT_ID,
|
||||
"accessory": {
|
||||
"type": "static_select",
|
||||
"placeholder": {"type": "plain_text", "text": "Select a team", "emoji": True},
|
||||
"options": team_options,
|
||||
"action_id": OnTeamChange.routing_uid(),
|
||||
"initial_option": team_options[initial_option_idx],
|
||||
},
|
||||
}
|
||||
return team_select
|
||||
|
||||
|
||||
def _get_selected_team_from_payload(payload, input_id_prefix):
|
||||
Team = apps.get_model("user_management", "Team")
|
||||
selected_team_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_TEAM_SELECT_ID][
|
||||
OnTeamChange.routing_uid()
|
||||
]["selected_option"]["value"]
|
||||
if selected_team_id == DEFAULT_TEAM_VALUE:
|
||||
return None
|
||||
team = Team.objects.filter(pk=selected_team_id).first()
|
||||
return team
|
||||
|
||||
|
||||
def _get_route_select(integration, value, input_id_prefix):
|
||||
route_options = []
|
||||
initial_option_idx = 0
|
||||
for idx, route in enumerate(integration.channel_filters.all()):
|
||||
filtering_term = f'"{route.filtering_term}"'
|
||||
if route.is_default:
|
||||
filtering_term = "default"
|
||||
if value == route:
|
||||
initial_option_idx = idx
|
||||
route_options.append(
|
||||
{
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"{filtering_term}",
|
||||
"emoji": True,
|
||||
},
|
||||
"value": f"{route.pk}",
|
||||
}
|
||||
)
|
||||
route_select = {
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": "Select a route"},
|
||||
"block_id": input_id_prefix + MANUAL_INCIDENT_ROUTE_SELECT_ID,
|
||||
"accessory": {
|
||||
"type": "static_select",
|
||||
"placeholder": {"type": "plain_text", "text": "Select a route", "emoji": True},
|
||||
"options": route_options,
|
||||
"initial_option": route_options[initial_option_idx],
|
||||
"action_id": OnRouteChange.routing_uid(),
|
||||
},
|
||||
}
|
||||
return route_select
|
||||
|
||||
|
||||
def _get_selected_route_from_payload(payload, input_id_prefix):
|
||||
ChannelFilter = apps.get_model("alerts", "ChannelFilter")
|
||||
selected_org_id = payload["view"]["state"]["values"][input_id_prefix + MANUAL_INCIDENT_ROUTE_SELECT_ID][
|
||||
OnRouteChange.routing_uid()
|
||||
]["selected_option"]["value"]
|
||||
channel_filter = ChannelFilter.objects.filter(pk=selected_org_id).first()
|
||||
return channel_filter
|
||||
|
||||
|
||||
def _get_and_change_input_id_prefix_from_metadata(metadata):
|
||||
old_input_id_prefix = metadata["input_id_prefix"]
|
||||
new_input_id_prefix = _generate_input_id_prefix()
|
||||
metadata["input_id_prefix"] = new_input_id_prefix
|
||||
return old_input_id_prefix, new_input_id_prefix, metadata
|
||||
|
||||
|
||||
def _get_title_input(payload):
|
||||
title_input_block = {
|
||||
"type": "input",
|
||||
"block_id": MANUAL_INCIDENT_TITLE_INPUT_ID,
|
||||
"label": {
|
||||
"type": "plain_text",
|
||||
"text": "Title:",
|
||||
},
|
||||
"element": {
|
||||
"type": "plain_text_input",
|
||||
"action_id": FinishCreateIncidentFromSlashCommand.routing_uid(),
|
||||
"placeholder": {
|
||||
"type": "plain_text",
|
||||
"text": " ",
|
||||
},
|
||||
},
|
||||
}
|
||||
if payload.get("text", None) is not None:
|
||||
title_input_block["element"]["initial_value"] = payload["text"]
|
||||
return title_input_block
|
||||
|
||||
|
||||
def _get_title_from_payload(payload):
|
||||
title = payload["view"]["state"]["values"][MANUAL_INCIDENT_TITLE_INPUT_ID][
|
||||
FinishCreateIncidentFromSlashCommand.routing_uid()
|
||||
]["value"]
|
||||
return title
|
||||
|
||||
|
||||
def _get_message_input(payload):
|
||||
message_input_block = {
|
||||
"type": "input",
|
||||
"block_id": MANUAL_INCIDENT_MESSAGE_INPUT_ID,
|
||||
"label": {
|
||||
"type": "plain_text",
|
||||
"text": "Message:",
|
||||
},
|
||||
"element": {
|
||||
"type": "plain_text_input",
|
||||
"action_id": FinishCreateIncidentFromSlashCommand.routing_uid(),
|
||||
"multiline": True,
|
||||
"placeholder": {
|
||||
"type": "plain_text",
|
||||
"text": " ",
|
||||
},
|
||||
},
|
||||
"optional": True,
|
||||
}
|
||||
if payload.get("message", {}).get("text") is not None:
|
||||
message_input_block["element"]["initial_value"] = payload["message"]["text"]
|
||||
return message_input_block
|
||||
|
||||
|
||||
def _get_message_from_payload(payload):
|
||||
message = (
|
||||
payload["view"]["state"]["values"][MANUAL_INCIDENT_MESSAGE_INPUT_ID][
|
||||
FinishCreateIncidentFromSlashCommand.routing_uid()
|
||||
]["value"]
|
||||
or ""
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
# _generate_input_id_prefix returns uniq str to not to preserve input's values between view update
|
||||
# https://api.slack.com/methods/views.update#markdown
|
||||
def _generate_input_id_prefix():
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
STEPS_ROUTING = [
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_MESSAGE_ACTION,
|
||||
"message_action_callback_id": StartCreateIncidentFromMessage.callback_id,
|
||||
"step": StartCreateIncidentFromMessage,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,
|
||||
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT,
|
||||
"block_action_id": OnOrgChange.routing_uid(),
|
||||
"step": OnOrgChange,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,
|
||||
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT,
|
||||
"block_action_id": OnTeamChange.routing_uid(),
|
||||
"step": OnTeamChange,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_BLOCK_ACTIONS,
|
||||
"block_action_type": scenario_step.BLOCK_ACTION_TYPE_STATIC_SELECT,
|
||||
"block_action_id": OnRouteChange.routing_uid(),
|
||||
"step": OnRouteChange,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION,
|
||||
"view_callback_id": FinishCreateIncidentFromMessage.routing_uid(),
|
||||
"step": FinishCreateIncidentFromMessage,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_SLASH_COMMAND,
|
||||
"command_name": StartCreateIncidentFromSlashCommand.command_name,
|
||||
"step": StartCreateIncidentFromSlashCommand,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION,
|
||||
"view_callback_id": FinishCreateIncidentFromSlashCommand.routing_uid(),
|
||||
"step": FinishCreateIncidentFromSlashCommand,
|
||||
},
|
||||
]
|
||||
|
|
@ -1,536 +0,0 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.slack.scenarios import scenario_step
|
||||
from apps.slack.slack_client import SlackClientWithErrorHandling
|
||||
from apps.slack.slack_client.exceptions import SlackAPIException
|
||||
|
||||
from .step_mixins import CheckAlertIsUnarchivedMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
class InvitedToChannelStep(scenario_step.ScenarioStep):
|
||||
|
||||
tags = [
|
||||
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
|
||||
]
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
if payload["event"]["user"] == slack_team_identity.bot_user_id:
|
||||
channel_id = payload["event"]["channel"]
|
||||
slack_client = SlackClientWithErrorHandling(slack_team_identity.bot_access_token)
|
||||
channel = slack_client.api_call("conversations.info", channel=channel_id)["channel"]
|
||||
|
||||
slack_team_identity.cached_channels.update_or_create(
|
||||
slack_id=channel["id"],
|
||||
defaults={
|
||||
"name": channel["name"],
|
||||
"is_archived": channel["is_archived"],
|
||||
"is_shared": channel["is_shared"],
|
||||
"last_populated": timezone.now().date(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info("Other user was invited to a channel with a bot.")
|
||||
|
||||
|
||||
class CloseEphemeralButtonStep(scenario_step.ScenarioStep):
|
||||
|
||||
random_prefix_for_routing = "qwe2id"
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
return JsonResponse({"response_type": "ephemeral", "delete_original": True})
|
||||
|
||||
|
||||
class CreateIncidentManuallyStep(scenario_step.ScenarioStep):
|
||||
command_name = [settings.SLACK_SLASH_COMMAND_NAME]
|
||||
tags = [
|
||||
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
|
||||
]
|
||||
|
||||
TITLE_INPUT_BLOCK_ID = "TITLE_INPUT"
|
||||
MESSAGE_INPUT_BLOCK_ID = "MESSAGE_INPUT"
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
try:
|
||||
channel_id = payload["event"]["channel"]
|
||||
except KeyError:
|
||||
channel_id = payload["channel_id"]
|
||||
|
||||
blocks = self.get_create_incident_blocks(payload, slack_team_identity, slack_user_identity)
|
||||
|
||||
view = {
|
||||
"type": "modal",
|
||||
"callback_id": FinishCreateIncidentViewStep.routing_uid(),
|
||||
"title": {
|
||||
"type": "plain_text",
|
||||
"text": "Create an Incident",
|
||||
},
|
||||
"close": {
|
||||
"type": "plain_text",
|
||||
"text": "Cancel",
|
||||
"emoji": True,
|
||||
},
|
||||
"submit": {
|
||||
"type": "plain_text",
|
||||
"text": "Submit",
|
||||
},
|
||||
"blocks": blocks,
|
||||
"private_metadata": json.dumps({"channel_id": channel_id}),
|
||||
}
|
||||
self._slack_client.api_call(
|
||||
"views.open",
|
||||
trigger_id=payload["trigger_id"],
|
||||
view=view,
|
||||
)
|
||||
|
||||
def get_create_incident_blocks(self, payload, slack_team_identity, slack_user_identity):
|
||||
blocks = []
|
||||
organization_selection_block = self.get_select_organization_route_element(
|
||||
slack_team_identity, slack_user_identity
|
||||
)
|
||||
title_incident_block = {
|
||||
"type": "input",
|
||||
"block_id": self.TITLE_INPUT_BLOCK_ID,
|
||||
"label": {
|
||||
"type": "plain_text",
|
||||
"text": "Title:",
|
||||
},
|
||||
"element": {
|
||||
"type": "plain_text_input",
|
||||
"action_id": FinishCreateIncidentViewStep.routing_uid(),
|
||||
"placeholder": {
|
||||
"type": "plain_text",
|
||||
"text": " ",
|
||||
},
|
||||
},
|
||||
}
|
||||
if payload.get("text", None) is not None:
|
||||
title_incident_block["element"]["initial_value"] = payload["text"]
|
||||
message_incident_block = {
|
||||
"type": "input",
|
||||
"block_id": self.MESSAGE_INPUT_BLOCK_ID,
|
||||
"label": {
|
||||
"type": "plain_text",
|
||||
"text": "Message:",
|
||||
},
|
||||
"element": {
|
||||
"type": "plain_text_input",
|
||||
"action_id": FinishCreateIncidentViewStep.routing_uid(),
|
||||
"multiline": True,
|
||||
"placeholder": {
|
||||
"type": "plain_text",
|
||||
"text": " ",
|
||||
},
|
||||
},
|
||||
"optional": True,
|
||||
}
|
||||
if payload.get("message", {}).get("text") is not None:
|
||||
message_incident_block["element"]["initial_value"] = payload["message"]["text"]
|
||||
|
||||
blocks.append(organization_selection_block)
|
||||
blocks.append(title_incident_block)
|
||||
blocks.append(message_incident_block)
|
||||
return blocks
|
||||
|
||||
|
||||
class FinishCreateIncidentViewStep(scenario_step.ScenarioStep):
|
||||
|
||||
tags = [
|
||||
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
|
||||
]
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
|
||||
ChannelFilter = apps.get_model("alerts", "ChannelFilter")
|
||||
|
||||
Alert = apps.get_model("alerts", "Alert")
|
||||
payload_values = payload["view"]["state"]["values"]
|
||||
title = payload_values[CreateIncidentManuallyStep.TITLE_INPUT_BLOCK_ID][self.routing_uid()]["value"]
|
||||
text = payload_values[CreateIncidentManuallyStep.MESSAGE_INPUT_BLOCK_ID][self.routing_uid()]["value"] or ""
|
||||
|
||||
private_metadata = json.loads(payload["view"]["private_metadata"])
|
||||
# update private metadata in payload to use it in alert rendering
|
||||
payload["view"]["private_metadata"] = private_metadata
|
||||
|
||||
channel_id = private_metadata["channel_id"]
|
||||
|
||||
alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration(
|
||||
organization=self.organization,
|
||||
integration=AlertReceiveChannel.INTEGRATION_MANUAL,
|
||||
deleted_at=None,
|
||||
defaults={"author": self.user},
|
||||
)
|
||||
try:
|
||||
self._slack_client.api_call(
|
||||
"chat.postEphemeral",
|
||||
channel=channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
text=":white_check_mark: Alert *{}* successfully submitted".format(title),
|
||||
)
|
||||
except SlackAPIException as e:
|
||||
if e.response["error"] == "channel_not_found":
|
||||
self._slack_client.api_call(
|
||||
"chat.postEphemeral",
|
||||
channel=slack_user_identity.im_channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
text=":white_check_mark: Alert *{}* successfully submitted".format(title),
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
user_verbal = self.user.get_user_verbal_for_team_for_slack()
|
||||
channel_filter_pk = payload["view"]["state"]["values"][
|
||||
scenario_step.ScenarioStep.SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID
|
||||
][scenario_step.ScenarioStep.SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID]["selected_option"]["value"].split("-")[1]
|
||||
channel_filter = ChannelFilter.objects.get(pk=channel_filter_pk)
|
||||
Alert.create(
|
||||
title=title,
|
||||
message="{} created by {}".format(
|
||||
text,
|
||||
user_verbal,
|
||||
),
|
||||
image_url=None,
|
||||
link_to_upstream_details=None,
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
raw_request_data=payload,
|
||||
integration_unique_data={
|
||||
"created_by": user_verbal,
|
||||
},
|
||||
force_route_id=channel_filter.pk,
|
||||
)
|
||||
|
||||
|
||||
class CreateIncidentSubmenuStep(scenario_step.ScenarioStep):
|
||||
callback_id = [
|
||||
"incident_create",
|
||||
"incident_create_staging",
|
||||
"incident_create_develop",
|
||||
]
|
||||
tags = [
|
||||
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
|
||||
]
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
try:
|
||||
image_url = payload["message"]["files"][0]["permalink"]
|
||||
except KeyError:
|
||||
image_url = None
|
||||
channel_id = payload["channel"]["id"]
|
||||
|
||||
private_metadata = {
|
||||
"channel_id": channel_id,
|
||||
"image_url": image_url,
|
||||
"message": {
|
||||
"user": payload["message"].get("user"),
|
||||
"text": payload["message"].get("text"),
|
||||
"ts": payload["message"].get("ts"),
|
||||
},
|
||||
}
|
||||
|
||||
organization_selection_block = self.get_select_organization_route_element(
|
||||
slack_team_identity, slack_user_identity
|
||||
)
|
||||
view = {
|
||||
"type": "modal",
|
||||
"callback_id": FinishCreateIncidentSubmenuStep.routing_uid(),
|
||||
"title": {
|
||||
"type": "plain_text",
|
||||
"text": "Create an Incident",
|
||||
},
|
||||
"close": {
|
||||
"type": "plain_text",
|
||||
"text": "Cancel",
|
||||
"emoji": True,
|
||||
},
|
||||
"submit": {
|
||||
"type": "plain_text",
|
||||
"text": "Submit",
|
||||
},
|
||||
"blocks": [organization_selection_block],
|
||||
"private_metadata": json.dumps(private_metadata),
|
||||
}
|
||||
self._slack_client.api_call(
|
||||
"views.open",
|
||||
trigger_id=payload["trigger_id"],
|
||||
view=view,
|
||||
)
|
||||
|
||||
|
||||
class FinishCreateIncidentSubmenuStep(scenario_step.ScenarioStep):
|
||||
|
||||
tags = [
|
||||
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
|
||||
]
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
|
||||
Alert = apps.get_model("alerts", "Alert")
|
||||
|
||||
private_metadata = json.loads(payload["view"]["private_metadata"])
|
||||
# update private metadata in payload to use it in alert rendering
|
||||
payload["view"]["private_metadata"] = private_metadata
|
||||
|
||||
channel_id = private_metadata["channel_id"]
|
||||
author = private_metadata["message"]["user"]
|
||||
|
||||
alert_receive_channel = AlertReceiveChannel.get_or_create_manual_integration(
|
||||
organization=self.organization,
|
||||
integration=AlertReceiveChannel.INTEGRATION_MANUAL,
|
||||
deleted_at=None,
|
||||
defaults={"author": self.user},
|
||||
)
|
||||
|
||||
author_username = "Unknown"
|
||||
if author:
|
||||
try:
|
||||
author_username = self._slack_client.api_call(
|
||||
"users.info",
|
||||
user=author,
|
||||
)
|
||||
author_username = author_username.get("user", {}).get("real_name", None)
|
||||
except SlackAPIException:
|
||||
pass
|
||||
payload["view"]["private_metadata"]["author_username"] = author_username
|
||||
|
||||
try:
|
||||
permalink = self._slack_client.api_call(
|
||||
"chat.getPermalink",
|
||||
channel=private_metadata["channel_id"],
|
||||
message_ts=private_metadata["message"]["ts"],
|
||||
)
|
||||
permalink = permalink.get("permalink", None)
|
||||
except SlackAPIException:
|
||||
permalink = None
|
||||
|
||||
permalink = "<{}|Original message...>".format(permalink) if permalink is not None else ""
|
||||
Alert.create(
|
||||
title="Message from {}".format(author_username),
|
||||
message="{}\n{}".format(private_metadata["message"]["text"], permalink),
|
||||
image_url=private_metadata["image_url"],
|
||||
# Link to the slack message is not here bc it redirects to browser
|
||||
link_to_upstream_details=None,
|
||||
alert_receive_channel=alert_receive_channel,
|
||||
raw_request_data=payload,
|
||||
integration_unique_data={"created_by": self.user.get_user_verbal_for_team_for_slack()},
|
||||
)
|
||||
try:
|
||||
self._slack_client.api_call(
|
||||
"chat.postEphemeral",
|
||||
channel=channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
text=":white_check_mark: Alert successfully submitted",
|
||||
)
|
||||
except SlackAPIException as e:
|
||||
if e.response["error"] == "channel_not_found" or e.response["error"] == "user_not_in_channel":
|
||||
self._slack_client.api_call(
|
||||
"chat.postEphemeral",
|
||||
channel=slack_user_identity.im_channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
text=":white_check_mark: Alert successfully submitted",
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
|
||||
class AddToResolutionoteStep(CheckAlertIsUnarchivedMixin, scenario_step.ScenarioStep):
|
||||
callback_id = [
|
||||
"add_resolution_note",
|
||||
"add_resolution_note_staging",
|
||||
"add_resolution_note_develop",
|
||||
]
|
||||
tags = [
|
||||
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
|
||||
]
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
SlackMessage = apps.get_model("slack", "SlackMessage")
|
||||
ResolutionNoteSlackMessage = apps.get_model("alerts", "ResolutionNoteSlackMessage")
|
||||
ResolutionNote = apps.get_model("alerts", "ResolutionNote")
|
||||
SlackUserIdentity = apps.get_model("slack", "SlackUserIdentity")
|
||||
|
||||
try:
|
||||
channel_id = payload["channel"]["id"]
|
||||
except KeyError:
|
||||
raise Exception("Channel was not found")
|
||||
|
||||
if self.organization and self.organization.general_log_channel_id is None:
|
||||
try:
|
||||
return self._slack_client.api_call(
|
||||
"chat.postEphemeral",
|
||||
channel=channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
attachments=CreateIncidentSubmenuStep.finish_configuration_attachments(self.organization),
|
||||
)
|
||||
except SlackAPIException as e:
|
||||
if e.response["error"] == "channel_not_found" or e.response["error"] == "user_not_in_channel":
|
||||
return self._slack_client.api_call(
|
||||
"chat.postEphemeral",
|
||||
channel=slack_user_identity.im_channel_id,
|
||||
user=slack_user_identity.slack_id,
|
||||
attachments=CreateIncidentSubmenuStep.finish_configuration_attachments(self.organization),
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
warning_text = "Unable to add this message to resolution note, this command works only in incident threads."
|
||||
|
||||
try:
|
||||
slack_message = SlackMessage.objects.get(
|
||||
slack_id=payload["message"]["thread_ts"],
|
||||
_slack_team_identity=slack_team_identity,
|
||||
channel_id=channel_id,
|
||||
)
|
||||
except KeyError:
|
||||
self.open_warning_window(payload, warning_text)
|
||||
return
|
||||
except SlackMessage.DoesNotExist:
|
||||
self.open_warning_window(payload, warning_text)
|
||||
return
|
||||
|
||||
try:
|
||||
alert_group = slack_message.get_alert_group()
|
||||
except SlackMessage.alert.RelatedObjectDoesNotExist as e:
|
||||
self.open_warning_window(payload, warning_text)
|
||||
print(
|
||||
f"Exception: tried to add message from thread to Resolution Note: "
|
||||
f"Slack Team Identity pk: {self.slack_team_identity.pk}, "
|
||||
f"Slack Message id: {slack_message.slack_id}"
|
||||
)
|
||||
raise e
|
||||
|
||||
if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group):
|
||||
return
|
||||
|
||||
if payload["message"]["type"] == "message" and "user" in payload["message"]:
|
||||
message_ts = payload["message_ts"]
|
||||
thread_ts = payload["message"]["thread_ts"]
|
||||
|
||||
result = self._slack_client.api_call(
|
||||
"chat.getPermalink",
|
||||
channel=channel_id,
|
||||
message_ts=message_ts,
|
||||
)
|
||||
permalink = None
|
||||
if result["permalink"] is not None:
|
||||
permalink = result["permalink"]
|
||||
|
||||
if payload["message"]["ts"] in [
|
||||
message.ts
|
||||
for message in alert_group.resolution_note_slack_messages.filter(added_to_resolution_note=True)
|
||||
]:
|
||||
warning_text = "Unable to add the same message again."
|
||||
self.open_warning_window(payload, warning_text)
|
||||
return
|
||||
|
||||
elif len(payload["message"]["text"]) > 2900:
|
||||
warning_text = (
|
||||
"Unable to add the message to Resolution note: the message is too long ({}). "
|
||||
"Max length - 2900 symbols.".format(len(payload["message"]["text"]))
|
||||
)
|
||||
self.open_warning_window(payload, warning_text)
|
||||
return
|
||||
|
||||
else:
|
||||
try:
|
||||
resolution_note_slack_message = ResolutionNoteSlackMessage.objects.get(
|
||||
ts=message_ts, thread_ts=thread_ts
|
||||
)
|
||||
except ResolutionNoteSlackMessage.DoesNotExist:
|
||||
text = payload["message"]["text"]
|
||||
text = text.replace("```", "")
|
||||
slack_message = SlackMessage.objects.get(
|
||||
slack_id=thread_ts,
|
||||
_slack_team_identity=slack_team_identity,
|
||||
channel_id=channel_id,
|
||||
)
|
||||
alert_group = slack_message.get_alert_group()
|
||||
author_slack_user_identity = SlackUserIdentity.objects.get(
|
||||
slack_id=payload["message"]["user"], slack_team_identity=slack_team_identity
|
||||
)
|
||||
author_user = self.organization.users.get(slack_user_identity=author_slack_user_identity)
|
||||
resolution_note_slack_message = ResolutionNoteSlackMessage(
|
||||
alert_group=alert_group,
|
||||
user=author_user,
|
||||
added_by_user=self.user,
|
||||
text=text,
|
||||
slack_channel_id=channel_id,
|
||||
thread_ts=thread_ts,
|
||||
ts=message_ts,
|
||||
permalink=permalink,
|
||||
)
|
||||
resolution_note_slack_message.added_to_resolution_note = True
|
||||
resolution_note_slack_message.save()
|
||||
resolution_note = resolution_note_slack_message.get_resolution_note()
|
||||
if resolution_note is None:
|
||||
ResolutionNote(
|
||||
alert_group=alert_group,
|
||||
author=resolution_note_slack_message.user,
|
||||
source=ResolutionNote.Source.SLACK,
|
||||
resolution_note_slack_message=resolution_note_slack_message,
|
||||
).save()
|
||||
else:
|
||||
resolution_note.recreate()
|
||||
alert_group.drop_cached_after_resolve_report_json()
|
||||
alert_group.schedule_cache_for_web()
|
||||
try:
|
||||
self._slack_client.api_call(
|
||||
"reactions.add",
|
||||
channel=channel_id,
|
||||
name="memo",
|
||||
timestamp=resolution_note_slack_message.ts,
|
||||
)
|
||||
except SlackAPIException:
|
||||
pass
|
||||
|
||||
self._update_slack_message(alert_group)
|
||||
else:
|
||||
warning_text = "Unable to add this message to resolution note."
|
||||
self.open_warning_window(payload, warning_text)
|
||||
return
|
||||
|
||||
|
||||
STEPS_ROUTING = [
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_SLASH_COMMAND,
|
||||
"command_name": CreateIncidentManuallyStep.command_name,
|
||||
"step": CreateIncidentManuallyStep,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_EVENT_CALLBACK,
|
||||
"event_type": scenario_step.EVENT_TYPE_MEMBER_JOINED_CHANNEL,
|
||||
"step": InvitedToChannelStep,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_INTERACTIVE_MESSAGE,
|
||||
"action_type": scenario_step.ACTION_TYPE_BUTTON,
|
||||
"action_name": CloseEphemeralButtonStep.routing_uid(),
|
||||
"step": CloseEphemeralButtonStep,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION,
|
||||
"view_callback_id": FinishCreateIncidentViewStep.routing_uid(),
|
||||
"step": FinishCreateIncidentViewStep,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION,
|
||||
"view_callback_id": FinishCreateIncidentSubmenuStep.routing_uid(),
|
||||
"step": FinishCreateIncidentSubmenuStep,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_MESSAGE_ACTION,
|
||||
"message_action_callback_id": CreateIncidentSubmenuStep.callback_id,
|
||||
"step": CreateIncidentSubmenuStep,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_MESSAGE_ACTION,
|
||||
"message_action_callback_id": AddToResolutionoteStep.callback_id,
|
||||
"step": AddToResolutionoteStep,
|
||||
},
|
||||
]
|
||||
|
|
@ -14,6 +14,144 @@ from .step_mixins import CheckAlertIsUnarchivedMixin
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AddToResolutionNoteStep(CheckAlertIsUnarchivedMixin, scenario_step.ScenarioStep):
|
||||
callback_id = [
|
||||
"add_resolution_note",
|
||||
"add_resolution_note_staging",
|
||||
"add_resolution_note_develop",
|
||||
]
|
||||
tags = [
|
||||
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
|
||||
]
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
|
||||
SlackMessage = apps.get_model("slack", "SlackMessage")
|
||||
ResolutionNoteSlackMessage = apps.get_model("alerts", "ResolutionNoteSlackMessage")
|
||||
ResolutionNote = apps.get_model("alerts", "ResolutionNote")
|
||||
SlackUserIdentity = apps.get_model("slack", "SlackUserIdentity")
|
||||
|
||||
try:
|
||||
channel_id = payload["channel"]["id"]
|
||||
except KeyError:
|
||||
raise Exception("Channel was not found")
|
||||
|
||||
warning_text = "Unable to add this message to resolution note, this command works only in incident threads."
|
||||
|
||||
try:
|
||||
slack_message = SlackMessage.objects.get(
|
||||
slack_id=payload["message"]["thread_ts"],
|
||||
_slack_team_identity=slack_team_identity,
|
||||
channel_id=channel_id,
|
||||
)
|
||||
except KeyError:
|
||||
self.open_warning_window(payload, warning_text)
|
||||
return
|
||||
except SlackMessage.DoesNotExist:
|
||||
self.open_warning_window(payload, warning_text)
|
||||
return
|
||||
|
||||
try:
|
||||
alert_group = slack_message.get_alert_group()
|
||||
except SlackMessage.alert.RelatedObjectDoesNotExist as e:
|
||||
self.open_warning_window(payload, warning_text)
|
||||
print(
|
||||
f"Exception: tried to add message from thread to Resolution Note: "
|
||||
f"Slack Team Identity pk: {self.slack_team_identity.pk}, "
|
||||
f"Slack Message id: {slack_message.slack_id}"
|
||||
)
|
||||
raise e
|
||||
|
||||
if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group):
|
||||
return
|
||||
|
||||
if payload["message"]["type"] == "message" and "user" in payload["message"]:
|
||||
message_ts = payload["message_ts"]
|
||||
thread_ts = payload["message"]["thread_ts"]
|
||||
|
||||
result = self._slack_client.api_call(
|
||||
"chat.getPermalink",
|
||||
channel=channel_id,
|
||||
message_ts=message_ts,
|
||||
)
|
||||
permalink = None
|
||||
if result["permalink"] is not None:
|
||||
permalink = result["permalink"]
|
||||
|
||||
if payload["message"]["ts"] in [
|
||||
message.ts
|
||||
for message in alert_group.resolution_note_slack_messages.filter(added_to_resolution_note=True)
|
||||
]:
|
||||
warning_text = "Unable to add the same message again."
|
||||
self.open_warning_window(payload, warning_text)
|
||||
return
|
||||
|
||||
elif len(payload["message"]["text"]) > 2900:
|
||||
warning_text = (
|
||||
"Unable to add the message to Resolution note: the message is too long ({}). "
|
||||
"Max length - 2900 symbols.".format(len(payload["message"]["text"]))
|
||||
)
|
||||
self.open_warning_window(payload, warning_text)
|
||||
return
|
||||
|
||||
else:
|
||||
try:
|
||||
resolution_note_slack_message = ResolutionNoteSlackMessage.objects.get(
|
||||
ts=message_ts, thread_ts=thread_ts
|
||||
)
|
||||
except ResolutionNoteSlackMessage.DoesNotExist:
|
||||
text = payload["message"]["text"]
|
||||
text = text.replace("```", "")
|
||||
slack_message = SlackMessage.objects.get(
|
||||
slack_id=thread_ts,
|
||||
_slack_team_identity=slack_team_identity,
|
||||
channel_id=channel_id,
|
||||
)
|
||||
alert_group = slack_message.get_alert_group()
|
||||
author_slack_user_identity = SlackUserIdentity.objects.get(
|
||||
slack_id=payload["message"]["user"], slack_team_identity=slack_team_identity
|
||||
)
|
||||
author_user = self.organization.users.get(slack_user_identity=author_slack_user_identity)
|
||||
resolution_note_slack_message = ResolutionNoteSlackMessage(
|
||||
alert_group=alert_group,
|
||||
user=author_user,
|
||||
added_by_user=self.user,
|
||||
text=text,
|
||||
slack_channel_id=channel_id,
|
||||
thread_ts=thread_ts,
|
||||
ts=message_ts,
|
||||
permalink=permalink,
|
||||
)
|
||||
resolution_note_slack_message.added_to_resolution_note = True
|
||||
resolution_note_slack_message.save()
|
||||
resolution_note = resolution_note_slack_message.get_resolution_note()
|
||||
if resolution_note is None:
|
||||
ResolutionNote(
|
||||
alert_group=alert_group,
|
||||
author=resolution_note_slack_message.user,
|
||||
source=ResolutionNote.Source.SLACK,
|
||||
resolution_note_slack_message=resolution_note_slack_message,
|
||||
).save()
|
||||
else:
|
||||
resolution_note.recreate()
|
||||
alert_group.drop_cached_after_resolve_report_json()
|
||||
alert_group.schedule_cache_for_web()
|
||||
try:
|
||||
self._slack_client.api_call(
|
||||
"reactions.add",
|
||||
channel=channel_id,
|
||||
name="memo",
|
||||
timestamp=resolution_note_slack_message.ts,
|
||||
)
|
||||
except SlackAPIException:
|
||||
pass
|
||||
|
||||
self._update_slack_message(alert_group)
|
||||
else:
|
||||
warning_text = "Unable to add this message to resolution note."
|
||||
self.open_warning_window(payload, warning_text)
|
||||
return
|
||||
|
||||
|
||||
class UpdateResolutionNoteStep(scenario_step.ScenarioStep):
|
||||
def process_signal(self, alert_group, resolution_note):
|
||||
if resolution_note.deleted_at:
|
||||
|
|
@ -236,6 +374,7 @@ class ResolutionNoteModalStep(CheckAlertIsUnarchivedMixin, scenario_step.Scenari
|
|||
]
|
||||
|
||||
RESOLUTION_NOTE_TEXT_BLOCK_ID = "resolution_note_text"
|
||||
RESOLUTION_NOTE_MESSAGES_MAX_COUNT = 25
|
||||
|
||||
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None, data=None):
|
||||
AlertGroup = apps.get_model("alerts", "AlertGroup")
|
||||
|
|
@ -298,7 +437,27 @@ class ResolutionNoteModalStep(CheckAlertIsUnarchivedMixin, scenario_step.Scenari
|
|||
blocks = []
|
||||
|
||||
other_resolution_notes = alert_group.resolution_notes.filter(~Q(source=ResolutionNote.Source.SLACK))
|
||||
resolution_note_slack_messages = alert_group.resolution_note_slack_messages.filter(posted_by_bot=False)
|
||||
resolution_note_slack_messages = alert_group.resolution_note_slack_messages.filter(
|
||||
posted_by_bot=False
|
||||
).order_by("-pk")
|
||||
if resolution_note_slack_messages.count() > self.RESOLUTION_NOTE_MESSAGES_MAX_COUNT:
|
||||
blocks.extend(
|
||||
[
|
||||
{
|
||||
"type": "divider",
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
":warning: Listing up to last {} thread messages, "
|
||||
"you can still add any other message using contextual menu actions."
|
||||
).format(self.RESOLUTION_NOTE_MESSAGES_MAX_COUNT),
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
if action_resolve:
|
||||
blocks.extend(
|
||||
[
|
||||
|
|
@ -332,7 +491,7 @@ class ResolutionNoteModalStep(CheckAlertIsUnarchivedMixin, scenario_step.Scenari
|
|||
]
|
||||
)
|
||||
|
||||
for message in resolution_note_slack_messages:
|
||||
for message in resolution_note_slack_messages[: self.RESOLUTION_NOTE_MESSAGES_MAX_COUNT]:
|
||||
user_verbal = message.user.get_user_verbal_for_team_for_slack(mention=True)
|
||||
blocks.append(
|
||||
{
|
||||
|
|
@ -603,4 +762,9 @@ STEPS_ROUTING = [
|
|||
"block_action_id": AddRemoveThreadMessageStep.routing_uid(),
|
||||
"step": AddRemoveThreadMessageStep,
|
||||
},
|
||||
{
|
||||
"payload_type": scenario_step.PAYLOAD_TYPE_MESSAGE_ACTION,
|
||||
"message_action_callback_id": AddToResolutionNoteStep.callback_id,
|
||||
"step": AddToResolutionNoteStep,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -135,28 +135,6 @@ class ScenarioStep(object):
|
|||
channel = user.im_channel_id
|
||||
return channel
|
||||
|
||||
@staticmethod
|
||||
def finish_configuration_attachments(organization):
|
||||
text = (
|
||||
f"A few steps left to finish configuration!\n"
|
||||
f"Go to your <{organization.web_link}?page=slack|OnCall workspace> and select default channel "
|
||||
f"for your incidents!"
|
||||
)
|
||||
return [
|
||||
{
|
||||
"color": "#008000",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": text,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def routing_uid(cls):
|
||||
return cls.random_prefix_for_routing + cls.__name__
|
||||
|
|
@ -431,55 +409,3 @@ class ScenarioStep(object):
|
|||
element["initial_option"] = initial_option
|
||||
|
||||
return element
|
||||
|
||||
def get_select_organization_route_element(self, slack_team_identity, slack_user_identity):
|
||||
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
|
||||
|
||||
organizations = slack_team_identity.organizations.filter(
|
||||
users__slack_user_identity=slack_user_identity
|
||||
).distinct()
|
||||
organizations_options = []
|
||||
|
||||
for organization in organizations:
|
||||
manual_integration = AlertReceiveChannel.get_or_create_manual_integration(
|
||||
organization=organization,
|
||||
integration=AlertReceiveChannel.INTEGRATION_MANUAL,
|
||||
deleted_at=None,
|
||||
defaults={"author": self.user},
|
||||
)
|
||||
|
||||
for route in manual_integration.channel_filters.all():
|
||||
filtering_term = f'"{route.filtering_term}"'
|
||||
if route.is_default:
|
||||
filtering_term = "default"
|
||||
organizations_options.append(
|
||||
{
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": f"{organization.org_title}: {filtering_term}",
|
||||
"emoji": True,
|
||||
},
|
||||
"value": f"{organization.pk}-{route.pk}",
|
||||
}
|
||||
)
|
||||
|
||||
organization_selection_block = {
|
||||
"type": "input",
|
||||
"block_id": ScenarioStep.SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID,
|
||||
"element": {
|
||||
"type": "static_select",
|
||||
"placeholder": {
|
||||
"type": "plain_text",
|
||||
"text": "Select organization",
|
||||
},
|
||||
"action_id": ScenarioStep.SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID,
|
||||
"options": organizations_options,
|
||||
"initial_option": organizations_options[0],
|
||||
},
|
||||
"label": {
|
||||
"type": "plain_text",
|
||||
"text": "Select organization and route:",
|
||||
"emoji": True,
|
||||
},
|
||||
}
|
||||
return organization_selection_block
|
||||
|
|
|
|||
|
|
@ -100,3 +100,80 @@ def test_get_resolution_notes_blocks_non_empty(
|
|||
]
|
||||
|
||||
assert blocks == expected_blocks
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_resolution_notes_blocks_latest_limit(
|
||||
make_organization_and_user_with_slack_identities,
|
||||
make_alert_receive_channel,
|
||||
make_alert_group,
|
||||
make_resolution_note_slack_message,
|
||||
):
|
||||
SlackResolutionNoteModalStep = ScenarioStep.get_step("resolution_note", "ResolutionNoteModalStep")
|
||||
organization, user, slack_team_identity, _ = make_organization_and_user_with_slack_identities()
|
||||
step = SlackResolutionNoteModalStep(slack_team_identity)
|
||||
|
||||
alert_receive_channel = make_alert_receive_channel(organization)
|
||||
alert_group = make_alert_group(alert_receive_channel)
|
||||
|
||||
max_count = SlackResolutionNoteModalStep.RESOLUTION_NOTE_MESSAGES_MAX_COUNT
|
||||
messages = [
|
||||
make_resolution_note_slack_message(alert_group=alert_group, user=user, added_by_user=user, ts=i, text=i)
|
||||
for i in range(max_count * 2)
|
||||
]
|
||||
|
||||
blocks = step.get_resolution_notes_blocks(alert_group, "", False)
|
||||
|
||||
expected_blocks = [
|
||||
{
|
||||
"type": "divider",
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
":warning: Listing up to last {} thread messages, "
|
||||
"you can still add any other message using contextual menu actions."
|
||||
).format(max_count),
|
||||
},
|
||||
},
|
||||
]
|
||||
for m in list(reversed(messages))[:max_count]:
|
||||
expected_blocks += [
|
||||
{
|
||||
"type": "divider",
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "{} <!date^{:.0f}^{{date_num}} {{time_secs}}|message_created_at>\n{}".format(
|
||||
m.user.get_user_verbal_for_team_for_slack(mention=True),
|
||||
float(m.ts),
|
||||
m.text,
|
||||
),
|
||||
},
|
||||
"accessory": {
|
||||
"type": "button",
|
||||
"style": "primary",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "Add",
|
||||
"emoji": True,
|
||||
},
|
||||
"action_id": "AddRemoveThreadMessageStep",
|
||||
"value": json.dumps(
|
||||
{
|
||||
"resolution_note_window_action": "edit",
|
||||
"msg_value": "add",
|
||||
"message_pk": m.pk,
|
||||
"resolution_note_pk": None,
|
||||
"alert_group_pk": alert_group.pk,
|
||||
}
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
assert blocks == expected_blocks
|
||||
|
|
|
|||
|
|
@ -16,11 +16,12 @@ from apps.auth_token.auth import PluginAuthentication
|
|||
from apps.base.utils import live_settings
|
||||
from apps.slack.scenarios.alertgroup_appearance import STEPS_ROUTING as ALERTGROUP_APPEARANCE_ROUTING
|
||||
from apps.slack.scenarios.distribute_alerts import STEPS_ROUTING as DISTRIBUTION_STEPS_ROUTING
|
||||
from apps.slack.scenarios.invited_to_channel import STEPS_ROUTING as INVITED_TO_CHANNEL_ROUTING
|
||||
from apps.slack.scenarios.manual_incident import STEPS_ROUTING as MANUAL_INCIDENT_ROUTING
|
||||
|
||||
# Importing routes from scenarios
|
||||
from apps.slack.scenarios.onboarding import STEPS_ROUTING as ONBOARDING_STEPS_ROUTING
|
||||
from apps.slack.scenarios.profile_update import STEPS_ROUTING as PROFILE_UPDATE_ROUTING
|
||||
from apps.slack.scenarios.public_menu import STEPS_ROUTING as PUBLIC_MENU_ROUTING
|
||||
from apps.slack.scenarios.resolution_note import STEPS_ROUTING as RESOLUTION_NOTE_ROUTING
|
||||
from apps.slack.scenarios.scenario_step import (
|
||||
EVENT_SUBTYPE_BOT_MESSAGE,
|
||||
|
|
@ -57,7 +58,7 @@ from .models import SlackActionRecord, SlackMessage, SlackTeamIdentity, SlackUse
|
|||
SCENARIOS_ROUTES = [] # Add all other routes here
|
||||
SCENARIOS_ROUTES.extend(ONBOARDING_STEPS_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(DISTRIBUTION_STEPS_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(PUBLIC_MENU_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(INVITED_TO_CHANNEL_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(SCHEDULES_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(SLACK_CHANNEL_INTEGRATION_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(ALERTGROUP_APPEARANCE_ROUTING)
|
||||
|
|
@ -65,6 +66,7 @@ SCENARIOS_ROUTES.extend(RESOLUTION_NOTE_ROUTING)
|
|||
SCENARIOS_ROUTES.extend(SLACK_USERGROUP_UPDATE_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(CHANNEL_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(PROFILE_UPDATE_ROUTING)
|
||||
SCENARIOS_ROUTES.extend(MANUAL_INCIDENT_ROUTING)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,17 @@ class TwilioClient:
|
|||
|
||||
def send_message(self, body, to):
|
||||
status_callback = create_engine_url(reverse("twilioapp:sms_status_events"))
|
||||
return self.twilio_api_client.messages.create(
|
||||
body=body, to=to, from_=self.twilio_number, status_callback=status_callback
|
||||
)
|
||||
try:
|
||||
return self.twilio_api_client.messages.create(
|
||||
body=body, to=to, from_=self.twilio_number, status_callback=status_callback
|
||||
)
|
||||
except TwilioRestException as e:
|
||||
# If status callback is not valid and not accessible from public url then trying to send message without it
|
||||
# https://www.twilio.com/docs/api/errors/21609
|
||||
if e.code == 21609:
|
||||
logger.warning("twilio_client.send_message: Twilio error 21609. Status Callback is not public url")
|
||||
return self.twilio_api_client.messages.create(body=body, to=to, from_=self.twilio_number)
|
||||
raise e
|
||||
|
||||
# Use responsibly
|
||||
def parse_number(self, number):
|
||||
|
|
@ -149,6 +157,17 @@ class TwilioClient:
|
|||
status_callback_method="POST",
|
||||
)
|
||||
except TwilioRestException as e:
|
||||
# If status callback is not valid and not accessible from public url then trying to make call without it
|
||||
# https://www.twilio.com/docs/api/errors/21609
|
||||
if e.code == 21609:
|
||||
logger.warning("twilio_client.make_call: Twilio error 21609. Status Callback is not public url")
|
||||
return self.twilio_api_client.calls.create(
|
||||
url=url,
|
||||
to=to,
|
||||
from_=self.twilio_number,
|
||||
method="GET",
|
||||
)
|
||||
|
||||
raise e
|
||||
|
||||
def create_log_record(self, **kwargs):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 3.2.13 on 2022-07-05 12:14
|
||||
|
||||
import apps.user_management.models.user
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('user_management', '0001_squashed_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='_timezone',
|
||||
field=models.CharField(default=None, max_length=50, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='working_hours',
|
||||
field=models.JSONField(default=apps.user_management.models.user.default_working_hours, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -30,6 +30,16 @@ def generate_public_primary_key_for_user():
|
|||
return new_public_primary_key
|
||||
|
||||
|
||||
def default_working_hours():
|
||||
weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday"]
|
||||
weekends = ["saturday", "sunday"]
|
||||
|
||||
working_hours = {day: [{"start": "09:00:00", "end": "17:00:00"}] for day in weekdays}
|
||||
working_hours |= {day: [] for day in weekends}
|
||||
|
||||
return working_hours
|
||||
|
||||
|
||||
class UserManager(models.Manager):
|
||||
@staticmethod
|
||||
def sync_for_team(team, api_members: list[dict]):
|
||||
|
|
@ -128,6 +138,10 @@ class User(models.Model):
|
|||
role = models.PositiveSmallIntegerField(choices=Role.choices())
|
||||
avatar_url = models.URLField()
|
||||
|
||||
# don't use "_timezone" directly, use the "timezone" property since it can be populated via slack user identity
|
||||
_timezone = models.CharField(max_length=50, null=True, default=None)
|
||||
working_hours = models.JSONField(null=True, default=default_working_hours)
|
||||
|
||||
notification = models.ManyToManyField("alerts.AlertGroup", through="alerts.UserHasNotification")
|
||||
|
||||
unverified_phone_number = models.CharField(max_length=20, null=True, default=None)
|
||||
|
|
@ -222,11 +236,17 @@ class User(models.Model):
|
|||
|
||||
@property
|
||||
def timezone(self):
|
||||
slack_user_identity = self.slack_user_identity
|
||||
if slack_user_identity:
|
||||
return slack_user_identity.timezone
|
||||
else:
|
||||
return None
|
||||
if self._timezone:
|
||||
return self._timezone
|
||||
|
||||
if self.slack_user_identity:
|
||||
return self.slack_user_identity.timezone
|
||||
|
||||
return None
|
||||
|
||||
@timezone.setter
|
||||
def timezone(self, value):
|
||||
self._timezone = value
|
||||
|
||||
def short(self):
|
||||
return {"username": self.username, "pk": self.public_primary_key, "avatar": self.avatar_url}
|
||||
|
|
|
|||
|
|
@ -12,39 +12,28 @@ is_demo_alert_enabled = False
|
|||
description = None
|
||||
|
||||
# Default templates
|
||||
slack_title = """{% set metadata = payload.view.private_metadata %}
|
||||
{%-if "message" in metadata -%}
|
||||
{% set title = "Message from @" + metadata.author_username %}
|
||||
{%- else -%}
|
||||
{% set title = payload.view.state["values"].TITLE_INPUT.FinishCreateIncidentViewStep.value %}
|
||||
{%- endif -%}
|
||||
*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ title }}>* via {{ integration_name }}
|
||||
slack_title = """\
|
||||
*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ payload.oncall.title }}>* via {{ integration_name }}
|
||||
{% if source_link %}
|
||||
(*<{{ source_link }}|source>*)
|
||||
{%- endif %}
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
slack_message = """{% set metadata = payload.view.private_metadata %}
|
||||
{% if "message" in metadata -%}
|
||||
{{ metadata.message.text }}
|
||||
slack_message = """{{ payload.oncall.message }}
|
||||
|
||||
<https://{{ payload.team.domain }}.slack.com/archives/{{ metadata.channel_id }}/{{ metadata.message.ts }} | Original message... >
|
||||
{%- else -%}
|
||||
{{ payload.view.state["values"].MESSAGE_INPUT.FinishCreateIncidentViewStep.value }}
|
||||
|
||||
created by {{ payload.user.name }}
|
||||
{%- endif -%}"""
|
||||
created by {{ payload.oncall.author_username }}
|
||||
"""
|
||||
|
||||
slack_image_url = None
|
||||
|
||||
web_title = """{% set metadata = payload.view.private_metadata %}
|
||||
{%-if "message" in metadata -%}
|
||||
{{ "Message from @" + metadata.author_username }}
|
||||
{%- else -%}
|
||||
{{ payload.view.state["values"].TITLE_INPUT.FinishCreateIncidentViewStep.value }}
|
||||
{%- endif -%}"""
|
||||
web_title = "{{ payload.oncall.title }}"
|
||||
|
||||
web_message = slack_message
|
||||
web_message = """{{ payload.oncall.message }}
|
||||
{% if source_link %}
|
||||
<{{ source_link }} | Link to the original message >
|
||||
{% endif %}
|
||||
created by {{ payload.oncall.author_username }}
|
||||
"""
|
||||
|
||||
web_image_url = slack_image_url
|
||||
|
||||
|
|
@ -62,11 +51,7 @@ telegram_message = slack_message
|
|||
|
||||
telegram_image_url = slack_image_url
|
||||
|
||||
source_link = """\
|
||||
{% set metadata = payload.view.private_metadata %}
|
||||
{%- if "message" in metadata %}
|
||||
https://{{ payload.team.domain }}.slack.com/archives/{{ payload.channel.id }}/{{ payload.message.ts }}
|
||||
{% endif -%}"""
|
||||
source_link = "{{ payload.oncall.permalink }}"
|
||||
|
||||
grouping_id = """{{ payload }}"""
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { UserGroup } from 'models/user_group/user_group.types';
|
|||
export enum ScheduleType {
|
||||
'Calendar',
|
||||
'Ical',
|
||||
'Web',
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
|
|
|
|||
|
|
@ -350,7 +350,7 @@ class SchedulesPage extends React.Component<SchedulesPageProps, SchedulesPageSta
|
|||
type tTypeToVerbal = {
|
||||
[key: number]: string;
|
||||
};
|
||||
const typeToVerbal: tTypeToVerbal = { 0: 'API/Terraform', 1: 'Ical' };
|
||||
const typeToVerbal: tTypeToVerbal = { 0: 'API/Terraform', 1: 'Ical', 2: 'Web' };
|
||||
return typeToVerbal[value];
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue