Merge pull request #1383 from grafana/dev

dev -> main
This commit is contained in:
Joey Orlando 2023-02-22 07:48:47 +01:00 committed by GitHub
commit 0fa5bca246
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1040 additions and 794 deletions

39
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,39 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/grafana-plugin"
schedule:
interval: "monthly"
labels:
- "pr:dependencies"
- "pr:no changelog"
- "pr:no public docs"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "monthly"
labels:
- "pr:dependencies"
- "pr:no changelog"
- "pr:no public docs"
- package-ecosystem: "github-actions"
# Workflow files stored in the
# default location of `.github/workflows`
directory: "/"
schedule:
interval: "monthly"
labels:
- "pr:dependencies"
- "pr:no changelog"
- "pr:no public docs"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "monthly"
labels:
- "pr:dependencies"
- "pr:no changelog"
- "pr:no public docs"

View file

@ -28,9 +28,11 @@ jobs:
uses: "actions/checkout@v3"
- name: "Clone website-sync Action"
# WEBSITE_SYNC_ONCALL is a fine-grained GitHub Personal Access Token that expires.
# It must be updated in the grafanabot GitHub account.
run: "git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.WEBSITE_SYNC_ONCALL }}@github.com/grafana/website-sync ./.github/actions/website-sync"
# WEBSITE_SYNC_TOKEN is a fine-grained GitHub Personal Access Token that expires.
# It must be regenerated in the grafanabot GitHub account and requires a Grafana organization
# GitHub administrator to update the organization secret.
# The IT helpdesk can update the organization secret.
run: "git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.WEBSITE_SYNC_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync"
- name: "Publish to website repository (next)"
uses: "./.github/actions/website-sync"
@ -39,8 +41,10 @@ jobs:
repository: "grafana/website"
branch: "master"
host: "github.com"
# PUBLISH_TO_WEBSITE_ONCALL is a fine-grained GitHub Personal Access Token that expires.
# It must be updated in the grafanabot GitHub account.
github_pat: "grafanabot:${{ secrets.PUBLISH_TO_WEBSITE_ONCALL }}"
# PUBLISH_TO_WEBSITE_TOKEN is a fine-grained GitHub Personal Access Token that expires.
# It must be regenerated in the grafanabot GitHub account and requires a Grafana organization
# GitHub administrator to update the organization secret.
# The IT helpdesk can update the organization secret.
github_pat: "grafanabot:${{ secrets.PUBLISH_TO_WEBSITE_TOKEN }}"
source_folder: "docs/sources"
target_folder: "content/docs/oncall/next"

View file

@ -58,9 +58,11 @@ jobs:
- name: "Clone website-sync Action"
if: "steps.has-matching-release-tag.outputs.bool == 'true'"
# WEBSITE_SYNC_ONCALL is a fine-grained GitHub Personal Access Token that expires.
# It must be updated in the grafanabot GitHub account.
run: "git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.WEBSITE_SYNC_ONCALL }}@github.com/grafana/website-sync ./.github/actions/website-sync"
# WEBSITE_SYNC_TOKEN is a fine-grained GitHub Personal Access Token that expires.
# It must be regenerated in the grafanabot GitHub account and requires a Grafana organization
# GitHub administrator to update the organization secret.
# The IT helpdesk can update the organization secret.
run: "git clone --single-branch --no-tags --depth 1 -b master https://grafanabot:${{ secrets.WEBSITE_SYNC_TOKEN }}@github.com/grafana/website-sync ./.github/actions/website-sync"
- name: "Publish to website repository (release)"
if: "steps.has-matching-release-tag.outputs.bool == 'true'"
@ -70,9 +72,11 @@ jobs:
repository: "grafana/website"
branch: "master"
host: "github.com"
# PUBLISH_TO_WEBSITE_ONCALL is a fine-grained GitHub Personal Access Token that expires.
# It must be updated in the grafanabot GitHub account.
github_pat: "grafanabot:${{ secrets.PUBLISH_TO_WEBSITE_ONCALL }}"
# PUBLISH_TO_WEBSITE_TOKEN is a fine-grained GitHub Personal Access Token that expires.
# It must be regenerated in the grafanabot GitHub account and requires a Grafana organization
# GitHub administrator to update the organization secret.
# The IT helpdesk can update the organization secret.
github_pat: "grafanabot:${{ secrets.PUBLISH_TO_WEBSITE_TOKEN }}"
source_folder: "docs/sources"
# Append ".x" to target to produce a v<major>.<minor>.x directory.
target_folder: "content/docs/oncall/${{ steps.target.outputs.target }}.x"

View file

@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v1.1.27 (2023-02-22)
### Added
- Added reCAPTCHA validation for requesting a mobile verification code
### Changed
- Added ratelimits for phone verification
### Fixed
- Fixed HTTP request to Google where when fetching an iCal, the response would sometimes contain HTML instead
of the expected iCal data
## v1.1.26 (2023-02-20)
### Fixed
@ -40,6 +55,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Incidents - Removed buttons column and replaced status with toggler ([#1237](https://github.com/grafana/oncall/issues/1237))
- Responsiveness changes across multiple pages (Incidents, Integrations, Schedules) ([#1237](https://github.com/grafana/oncall/issues/1237))
- Link to source was added
- Header of Incident page was reworked: clickable labels instead of just names, users section was deleted
- "Go to Integration" button was deleted, because the functionality was moved to clickable labels
## v1.1.23 (2023-02-06)

View file

@ -18,10 +18,13 @@ from apps.alerts.escalation_snapshot.snapshot_classes import (
)
from apps.alerts.escalation_snapshot.utils import eta_for_escalation_step_notify_if_time
from apps.alerts.tasks import calculate_escalation_finish_time, escalate_alert_group
from apps.slack.scenarios.scenario_step import ScenarioStep
logger = logging.getLogger(__name__)
# Is a delay to prevent intermediate activity by system in case user is doing some multi-step action.
# For example if user wants to unack and ack we don't need to launch escalation right after unack.
START_ESCALATION_DELAY = 10
class EscalationSnapshotMixin:
"""
@ -239,7 +242,7 @@ class EscalationSnapshotMixin:
if raw_next_step_eta:
return parse(raw_next_step_eta).replace(tzinfo=pytz.UTC)
def start_escalation_if_needed(self, countdown=ScenarioStep.CROSS_ACTION_DELAY, eta=None):
def start_escalation_if_needed(self, countdown=START_ESCALATION_DELAY, eta=None):
"""
:type self:AlertGroup
"""

View file

@ -214,13 +214,9 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer):
)
if self.alert_group.invitations.filter(is_active=True).count() < 5:
slack_team_identity = self.alert_group.channel.organization.slack_team_identity
action_id = ScenarioStep.get_step("distribute_alerts", "InviteOtherPersonToIncident").routing_uid()
text = "Invite..."
invitation_element = ScenarioStep(
slack_team_identity,
self.alert_group.channel.organization,
).get_select_user_element(action_id, text=text)
invitation_element = self._get_select_user_element(action_id, text=text)
buttons.append(invitation_element)
if not self.alert_group.acknowledged:
if not self.alert_group.silenced:
@ -362,3 +358,74 @@ class AlertGroupSlackRenderer(AlertGroupBaseRenderer):
"actions": buttons,
}
]
def _get_select_user_element(
self, action_id, multi_select=False, initial_user=None, initial_users_list=None, text=None
):
MAX_STATIC_SELECT_OPTIONS = 100
if not text:
text = f"Select User{'s' if multi_select else ''}"
element = {
"action_id": action_id,
"type": "multi_static_select" if multi_select else "static_select",
"placeholder": {
"type": "plain_text",
"text": text,
"emoji": True,
},
}
users = self.alert_group.channel.organization.users.all().select_related("slack_user_identity")
users_count = users.count()
options = []
for user in users:
user_verbal = f"{user.get_user_verbal_for_team_for_slack()}"
if len(user_verbal) > 75:
user_verbal = user_verbal[:72] + "..."
option = {"text": {"type": "plain_text", "text": user_verbal}, "value": json.dumps({"user_id": user.pk})}
options.append(option)
if users_count > MAX_STATIC_SELECT_OPTIONS:
option_groups = []
option_groups_chunks = [
options[x : x + MAX_STATIC_SELECT_OPTIONS] for x in range(0, len(options), MAX_STATIC_SELECT_OPTIONS)
]
for option_group in option_groups_chunks:
option_group = {"label": {"type": "plain_text", "text": " "}, "options": option_group}
option_groups.append(option_group)
element["option_groups"] = option_groups
elif users_count == 0: # strange case when there are no users to select
option = {
"text": {"type": "plain_text", "text": "No users to select"},
"value": json.dumps({"user_id": None}),
}
options.append(option)
element["options"] = options
return element
else:
element["options"] = options
# add initial option
if multi_select and initial_users_list:
if users_count <= MAX_STATIC_SELECT_OPTIONS:
initial_options = []
for user in users:
user_verbal = f"{user.get_user_verbal_for_team_for_slack()}"
option = {
"text": {"type": "plain_text", "text": user_verbal},
"value": json.dumps({"user_id": user.pk}),
}
initial_options.append(option)
element["initial_options"] = initial_options
elif not multi_select and initial_user:
user_verbal = f"{initial_user.get_user_verbal_for_team_for_slack()}"
initial_option = {
"text": {"type": "plain_text", "text": user_verbal},
"value": json.dumps({"user_id": initial_user.pk}),
}
element["initial_option"] = initial_option
return element

View file

@ -3,6 +3,7 @@ import time
import typing
from django.conf import settings
from drf_recaptcha.fields import ReCaptchaV3Field
from rest_framework import serializers
from apps.api.permissions import DONT_USE_LEGACY_PERMISSION_MAPPING
@ -212,3 +213,7 @@ class FilterUserSerializer(EagerLoadingMixin, serializers.ModelSerializer):
"pk",
"username",
]
class MobileVerificationCodeRecaptchaSerializer(serializers.Serializer):
recaptcha = ReCaptchaV3Field(action="mobile_verification_code")

View file

@ -1,7 +1,9 @@
from unittest.mock import Mock, patch
import pytest
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
@ -13,6 +15,12 @@ from apps.base.models import UserNotificationPolicy
from apps.user_management.models.user import default_working_hours
@pytest.fixture(autouse=True)
def clear_cache():
# Ratelimit keys are stored in cache, clean to prevent ratelimits
cache.clear()
@pytest.mark.django_db
def test_update_user(
make_organization,
@ -653,7 +661,6 @@ def test_admin_can_verify_own_phone(
make_user_auth_headers,
):
_, user, token = make_organization_and_user_with_plugin_token(role=LegacyAccessControlRole.ADMIN)
client = APIClient()
url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key})
@ -1499,3 +1506,123 @@ def test_check_availability_other_user(make_organization_and_user_with_plugin_to
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
@patch(
"apps.api.throttlers.GetPhoneVerificationCodeThrottlerPerUser.get_throttle_limits",
return_value=(1, 10 * 60),
)
@patch("apps.api.throttlers.VerifyPhoneNumberThrottlerPerUser.get_throttle_limits", return_value=(1, 10 * 60))
@pytest.mark.django_db
def test_phone_number_verification_flow_ratelimit_per_user(
mock_verification_start,
mocked_verification_check,
mocked_get_phone_verification_code_get_throttle_limits,
mocked_get_phone_verify_phone_number_limits,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
_, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:user-get-verification-code", kwargs={"pk": user.public_primary_key})
# first get_verification_code request is succesfull
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
# second get_verification_code request is ratelimited
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key})
# first verify_number request is succesfull, because it uses different ratelimit scope
response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key})
# second verify_number request is succesfull, because it ratelimited
response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=Mock())
@patch("apps.twilioapp.phone_manager.PhoneManager.verify_phone_number", return_value=(True, None))
@patch(
"apps.api.throttlers.GetPhoneVerificationCodeThrottlerPerOrg.get_throttle_limits",
return_value=(1, 10 * 60),
)
@patch("apps.api.throttlers.VerifyPhoneNumberThrottlerPerOrg.get_throttle_limits", return_value=(1, 10 * 60))
@pytest.mark.django_db
def test_phone_number_verification_flow_ratelimit_per_org(
mock_verification_start,
mocked_verification_check,
mocked_get_phone_verification_code_get_throttle_limits,
mocked_get_phone_verify_phone_number_limits,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
make_user_for_organization,
):
"""
This test is checks per-org ratelimits for phone verification flow.
It makes two get_verification_code and two verify_number requests from different users and expect that second call will be ratelimited.
"""
org, user, token = make_organization_and_user_with_plugin_token()
second_user = make_user_for_organization(org)
client = APIClient()
url = reverse("api-internal:user-get-verification-code", kwargs={"pk": user.public_primary_key})
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
url = reverse("api-internal:user-get-verification-code", kwargs={"pk": second_user.public_primary_key})
response = client.get(url, format="json", **make_user_auth_headers(second_user, token))
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
url = reverse("api-internal:user-verify-number", kwargs={"pk": user.public_primary_key})
response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
url = reverse("api-internal:user-verify-number", kwargs={"pk": second_user.public_primary_key})
response = client.put(f"{url}?token=12345", format="json", **make_user_auth_headers(second_user, token))
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
@patch("apps.twilioapp.phone_manager.PhoneManager.send_verification_code", return_value=True)
@pytest.mark.parametrize(
"recaptcha_testing_pass,expected_status",
[
(True, status.HTTP_200_OK),
(False, status.HTTP_400_BAD_REQUEST),
],
)
@pytest.mark.django_db
def test_phone_number_verification_recaptcha(
mock_verification_start,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
recaptcha_testing_pass,
expected_status,
):
_, user, token = make_organization_and_user_with_plugin_token()
recaptcha_token = "asdasdfasdf"
client = APIClient()
request_headers = {"HTTP_X-OnCall-Recaptcha": recaptcha_token, **make_user_auth_headers(user, token)}
url = reverse("api-internal:user-get-verification-code", kwargs={"pk": user.public_primary_key})
with override_settings(DRF_RECAPTCHA_TESTING_PASS=recaptcha_testing_pass):
response = client.get(url, format="json", **request_headers)
assert response.status_code == expected_status
if expected_status == status.HTTP_200_OK:
mock_verification_start.assert_called_once_with()
else:
mock_verification_start.assert_not_called()

View file

@ -1 +1,8 @@
from .demo_alert_throttler import DemoAlertThrottler # noqa: F401
from .phone_verification_throttler import ( # noqa: F401
GetPhoneVerificationCodeThrottlerPerOrg,
GetPhoneVerificationCodeThrottlerPerUser,
VerifyPhoneNumberThrottlerPerOrg,
VerifyPhoneNumberThrottlerPerUser,
)
from .test_call_throttler import TestCallThrottler # noqa: F401

View file

@ -0,0 +1,49 @@
from common.api_helpers.custom_rate_scoped_throttler import CustomRateScopedThrottler
class GetPhoneVerificationCodeThrottlerPerUser(CustomRateScopedThrottler):
def get_scope(self):
return "get_phone_verification_code_per_user"
def get_throttle_limits(self):
return 5, 10 * 60
class VerifyPhoneNumberThrottlerPerUser(CustomRateScopedThrottler):
def get_scope(self):
return "verify_phone_number_per_user"
def get_throttle_limits(self):
return 50, 10 * 60
class GetPhoneVerificationCodeThrottlerPerOrg(CustomRateScopedThrottler):
def get_scope(self):
return "get_phone_verification_code_per_org"
def get_throttle_limits(self):
return 50, 10 * 60
def get_cache_key(self, request, view):
if request.user.is_authenticated:
ident = request.user.organization.pk
else:
ident = self.get_ident(request)
return self.cache_format % {"scope": self.scope, "ident": ident}
class VerifyPhoneNumberThrottlerPerOrg(CustomRateScopedThrottler):
def get_scope(self):
return "verify_phone_number_per_org"
def get_throttle_limits(self):
return 50, 10 * 60
def get_cache_key(self, request, view):
if request.user.is_authenticated:
ident = request.user.organization.pk
else:
ident = self.get_ident(request)
return self.cache_format % {"scope": self.scope, "ident": ident}

View file

@ -0,0 +1,6 @@
from rest_framework.throttling import UserRateThrottle
class TestCallThrottler(UserRateThrottle):
scope = "make_test_call"
rate = "5/m"

View file

@ -23,7 +23,19 @@ from apps.api.permissions import (
user_is_authorized,
)
from apps.api.serializers.team import TeamSerializer
from apps.api.serializers.user import FilterUserSerializer, UserHiddenFieldsSerializer, UserSerializer
from apps.api.serializers.user import (
FilterUserSerializer,
MobileVerificationCodeRecaptchaSerializer,
UserHiddenFieldsSerializer,
UserSerializer,
)
from apps.api.throttlers import (
GetPhoneVerificationCodeThrottlerPerOrg,
GetPhoneVerificationCodeThrottlerPerUser,
TestCallThrottler,
VerifyPhoneNumberThrottlerPerOrg,
VerifyPhoneNumberThrottlerPerUser,
)
from apps.auth_token.auth import PluginAuthentication
from apps.auth_token.constants import SCHEDULE_EXPORT_TOKEN_NAME
from apps.auth_token.models import UserScheduleExportAuthToken
@ -279,17 +291,43 @@ class UserView(
def timezone_options(self, request):
return Response(pytz.common_timezones)
@action(detail=True, methods=["get"])
@action(
detail=True,
methods=["get"],
throttle_classes=[GetPhoneVerificationCodeThrottlerPerUser, GetPhoneVerificationCodeThrottlerPerOrg],
)
def get_verification_code(self, request, pk):
"""
See `DRF_RECAPTCHA_TESTING` in `settings/base.py`
and [here](https://github.com/llybin/drf-recaptcha#testing) to better understand
when the recaptcha checks are actually made
"""
logger.info("Validating reCAPTCHA code")
serializer = MobileVerificationCodeRecaptchaSerializer(
data={"recaptcha": request.headers.get("X-OnCall-Recaptcha", "some-non-null-value")},
context={"request": request},
)
if not serializer.is_valid():
logger.warning(f"Invalid reCAPTCHA validation: {serializer._errors}")
return Response(status=status.HTTP_400_BAD_REQUEST)
logger.info("reCAPTCHA code is valid")
user = self.get_object()
phone_manager = PhoneManager(user)
code_sent = phone_manager.send_verification_code()
if not code_sent:
logger.warning(f"Mobile app verification code was not successfully sent")
return Response(status=status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["put"])
@action(
detail=True,
methods=["put"],
throttle_classes=[VerifyPhoneNumberThrottlerPerUser, VerifyPhoneNumberThrottlerPerOrg],
)
def verify_number(self, request, pk):
target_user = self.get_object()
code = request.query_params.get("token", None)
@ -327,7 +365,7 @@ class UserView(
)
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
@action(detail=True, methods=["post"], throttle_classes=[TestCallThrottler])
def make_test_call(self, request, pk):
user = self.get_object()
phone_number = user.verified_phone_number

View file

@ -9,7 +9,8 @@ from django.core.cache import cache
from apps.alerts.models.alert_group_counter import ConcurrentUpdateError
from apps.alerts.tasks import resolve_alert_group_by_source_if_needed
from apps.slack.scenarios.scenario_step import SlackAPIException, SlackClientWithErrorHandling
from apps.slack.slack_client import SlackClientWithErrorHandling
from apps.slack.slack_client.exceptions import SlackAPIException
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
from common.custom_celery_tasks.create_alert_base_task import CreateAlertBaseTask

View file

@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import PropertyMock, patch
import pytest
from django.core.cache import cache
@ -7,27 +7,25 @@ from rest_framework import status
from rest_framework.test import APIClient
@patch("apps.public_api.throttlers.user_throttle.UserThrottle.get_throttle_limits")
@pytest.mark.django_db
def test_throttling(mocked_throttle_limits, make_organization_and_user_with_token):
MAX_REQUESTS = 1
PERIOD = 360
def test_throttling(make_organization_and_user_with_token):
with patch("apps.public_api.throttlers.user_throttle.UserThrottle.rate", new_callable=PropertyMock) as mocked_rate:
mocked_rate.return_value = "1/m"
_, _, token = make_organization_and_user_with_token()
cache.clear()
_, _, token = make_organization_and_user_with_token()
cache.clear()
client = APIClient()
client = APIClient()
mocked_throttle_limits.return_value = MAX_REQUESTS, PERIOD
url = reverse("api-public:alert_groups-list")
url = reverse("api-public:alert_groups-list")
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_200_OK
assert response.status_code == status.HTTP_200_OK
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
response = client.get(url, format="json", HTTP_AUTHORIZATION=f"{token}")
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
# make sure RateLimitHeadersMixin used
assert response.has_header("RateLimit-Reset")
# make sure RateLimitHeadersMixin used
assert response.has_header("RateLimit-Reset")

View file

@ -2,42 +2,5 @@ from rest_framework.throttling import UserRateThrottle
class UserThrottle(UserRateThrottle):
"""
__init__ and allow_request are overridden because we want rate 300/5m,
but default rate parser implementation doesn't allow to specify length of period (only m, d, etc.)
(See SimpleRateThrottle.parse_rate)
"""
def __init__(self):
self.num_requests, self.duration = self.get_throttle_limits()
def get_throttle_limits(self):
"""
This method exits for speed up tests.
:return tuple requests/seconds
"""
return 300, 60
def allow_request(self, request, view):
"""
Implement the check to see if the request should be throttled.
On success calls `throttle_success`.
On failure calls `throttle_failure`.
"""
self.key = self.get_cache_key(request, view)
if self.key is None:
return True
self.history = self.cache.get(self.key, [])
self.now = self.timer()
# Drop any requests from the history which have now passed the
# throttle duration
while self.history and self.history[-1] <= self.now - self.duration:
self.history.pop()
if len(self.history) >= self.num_requests:
return self.throttle_failure()
return self.throttle_success()
scope = "public_api"
rate = "300/m"

View file

@ -576,7 +576,7 @@ def fetch_ical_file_or_get_error(ical_url):
cached_ical_file = None
ical_file_error = None
try:
new_ical_file = requests.get(ical_url, timeout=10).text
new_ical_file = fetch_ical_file(ical_url)
Calendar.from_ical(new_ical_file)
cached_ical_file = new_ical_file
except requests.exceptions.RequestException:
@ -587,6 +587,15 @@ def fetch_ical_file_or_get_error(ical_url):
return cached_ical_file, ical_file_error
def fetch_ical_file(ical_url):
# without user-agent header google calendar sometimes returns text/html instead of text/calendar
headers = {"User-Agent": "Grafana OnCall"}
r = requests.get(ical_url, headers=headers, timeout=10)
logger.info(f"fetch_ical_file: content-type={r.headers.get('Content-Type')}")
ical_file = r.text
return ical_file
def create_base_icalendar(name: str) -> Calendar:
cal = Calendar()
cal.add("calscale", "GREGORIAN")

View file

@ -0,0 +1,136 @@
import logging
from django.apps import apps
from apps.slack.constants import SLACK_RATE_LIMIT_DELAY
from apps.slack.slack_client import SlackClientWithErrorHandling
from apps.slack.slack_client.exceptions import (
SlackAPIChannelArchivedException,
SlackAPIException,
SlackAPIRateLimitException,
SlackAPITokenException,
)
logger = logging.getLogger(__name__)
class AlertGroupSlackService:
def __init__(self, slack_team_identity, slack_client=None):
self.slack_team_identity = slack_team_identity
if slack_client is not None:
self._slack_client = slack_client
else:
self._slack_client = SlackClientWithErrorHandling(slack_team_identity.bot_access_token)
def update_alert_group_slack_message(self, alert_group):
logger.info(f"Started _update_slack_message for alert_group {alert_group.pk}")
SlackMessage = apps.get_model("slack", "SlackMessage")
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
slack_message = alert_group.slack_message
attachments = alert_group.render_slack_attachments()
blocks = alert_group.render_slack_blocks()
logger.info(f"Update message for alert_group {alert_group.pk}")
try:
self._slack_client.api_call(
"chat.update",
channel=slack_message.channel_id,
ts=slack_message.slack_id,
attachments=attachments,
blocks=blocks,
)
logger.info(f"Message has been updated for alert_group {alert_group.pk}")
except SlackAPIRateLimitException as e:
if alert_group.channel.integration != AlertReceiveChannel.INTEGRATION_MAINTENANCE:
if not alert_group.channel.is_rate_limited_in_slack:
delay = e.response.get("rate_limit_delay") or SLACK_RATE_LIMIT_DELAY
alert_group.channel.start_send_rate_limit_message_task(delay)
logger.info(
f"Message has not been updated for alert_group {alert_group.pk} due to slack rate limit."
)
else:
raise e
except SlackAPIException as e:
if e.response["error"] == "message_not_found":
logger.info(f"message_not_found for alert_group {alert_group.pk}, trying to post new message")
result = self._slack_client.api_call(
"chat.postMessage", channel=slack_message.channel_id, attachments=attachments, blocks=blocks
)
slack_message_updated = SlackMessage(
slack_id=result["ts"],
organization=slack_message.organization,
_slack_team_identity=slack_message.slack_team_identity,
channel_id=slack_message.channel_id,
alert_group=alert_group,
)
slack_message_updated.save()
alert_group.slack_message = slack_message_updated
alert_group.save(update_fields=["slack_message"])
logger.info(f"Message has been posted for alert_group {alert_group.pk}")
elif e.response["error"] == "is_inactive": # deleted channel error
logger.info(f"Skip updating slack message for alert_group {alert_group.pk} due to is_inactive")
elif e.response["error"] == "account_inactive":
logger.info(f"Skip updating slack message for alert_group {alert_group.pk} due to account_inactive")
elif e.response["error"] == "channel_not_found":
logger.info(f"Skip updating slack message for alert_group {alert_group.pk} due to channel_not_found")
else:
raise e
logger.info(f"Finished _update_slack_message for alert_group {alert_group.pk}")
def publish_message_to_alert_group_thread(
self, alert_group, attachments=[], mrkdwn=True, unfurl_links=True, text=None
):
# TODO: refactor checking the possibility of sending message to slack
# do not try to post message to slack if integration is rate limited
if alert_group.channel.is_rate_limited_in_slack:
return
SlackMessage = apps.get_model("slack", "SlackMessage")
slack_message = alert_group.get_slack_message()
channel_id = slack_message.channel_id
try:
result = self._slack_client.api_call(
"chat.postMessage",
channel=channel_id,
text=text,
attachments=attachments,
thread_ts=slack_message.slack_id,
mrkdwn=mrkdwn,
unfurl_links=unfurl_links,
)
except SlackAPITokenException as e:
logger.warning(
f"Unable to post message to thread in slack. "
f"Slack team identity pk: {self.slack_team_identity.pk}.\n"
f"{e}"
)
except SlackAPIChannelArchivedException:
logger.warning(
f"Unable to post message to thread in slack. "
f"Slack team identity pk: {self.slack_team_identity.pk}.\n"
f"Reason: 'is_archived'"
)
except SlackAPIException as e:
if e.response["error"] == "channel_not_found": # channel was deleted
logger.warning(
f"Unable to post message to thread in slack. "
f"Slack team identity pk: {self.slack_team_identity.pk}.\n"
f"Reason: 'channel_not_found'"
)
elif e.response["error"] == "invalid_auth":
logger.warning(
f"Unable to post message to thread in slack. "
f"Slack team identity pk: {self.slack_team_identity.pk}.\n"
f"Reason: 'invalid_auth'"
)
else:
raise e
else:
SlackMessage(
slack_id=result["ts"],
organization=alert_group.channel.organization,
_slack_team_identity=self.slack_team_identity,
channel_id=channel_id,
alert_group=alert_group,
).save()

View file

@ -1,17 +1,21 @@
from django.db import models
from apps.slack.scenarios.scenario_step import ScenarioStep
# from apps.slack.scenarios.scenario_step import ScenarioStep
class SlackActionRecord(models.Model):
"""
Legacy model, should be removed.
"""
ON_CALL_ROUTINE = [
ScenarioStep.get_step("distribute_alerts", "CustomButtonProcessStep").routing_uid(),
ScenarioStep.get_step("distribute_alerts", "StopInvitationProcess").routing_uid(),
ScenarioStep.get_step("distribute_alerts", "InviteOtherPersonToIncident").routing_uid(),
ScenarioStep.get_step("distribute_alerts", "AcknowledgeGroupStep").routing_uid(),
ScenarioStep.get_step("distribute_alerts", "UnAcknowledgeGroupStep").routing_uid(),
ScenarioStep.get_step("distribute_alerts", "ResolveGroupStep").routing_uid(),
ScenarioStep.get_step("distribute_alerts", "SilenceGroupStep").routing_uid(),
# ScenarioStep.get_step("distribute_alerts", "CustomButtonProcessStep").routing_uid(),
# ScenarioStep.get_step("distribute_alerts", "StopInvitationProcess").routing_uid(),
# ScenarioStep.get_step("distribute_alerts", "InviteOtherPersonToIncident").routing_uid(),
# ScenarioStep.get_step("distribute_alerts", "AcknowledgeGroupStep").routing_uid(),
# ScenarioStep.get_step("distribute_alerts", "UnAcknowledgeGroupStep").routing_uid(),
# ScenarioStep.get_step("distribute_alerts", "ResolveGroupStep").routing_uid(),
# ScenarioStep.get_step("distribute_alerts", "SilenceGroupStep").routing_uid(),
]
organization = models.ForeignKey("user_management.Organization", on_delete=models.CASCADE, related_name="actions")

View file

@ -217,3 +217,32 @@ class SlackMessage(models.Model):
pass
else:
raise e
@classmethod
def get_alert_group_from_slack_message_payload(cls, slack_team_identity, payload):
message_ts = payload.get("message_ts") or payload["container"]["message_ts"] # interactive message or block
channel_id = payload["channel"]["id"]
try:
slack_message = cls.objects.get(
slack_id=message_ts,
_slack_team_identity=slack_team_identity,
channel_id=channel_id,
)
alert_group = slack_message.get_alert_group()
except cls.DoesNotExist as e:
logger.error(
f"Tried to get SlackMessage from message_ts:"
f"slack_team_identity_id={slack_team_identity.pk},"
f"message_ts={message_ts}"
)
raise e
except cls.alert.RelatedObjectDoesNotExist as e:
logger.error(
f"Tried to get AlertGroup from SlackMessage:"
f"slack_team_identity_id={slack_team_identity.pk},"
f"message_ts={message_ts}"
)
raise e
return alert_group

View file

@ -16,15 +16,10 @@ from .step_mixins import CheckAlertIsUnarchivedMixin, IncidentActionsAccessContr
class OpenAlertAppearanceDialogStep(
CheckAlertIsUnarchivedMixin, IncidentActionsAccessControlMixin, scenario_step.ScenarioStep
):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "open Alert Appearance"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
AlertGroup = apps.get_model("alerts", "AlertGroup")
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
@ -216,12 +211,7 @@ class OpenAlertAppearanceDialogStep(
class UpdateAppearanceStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
AlertGroup = apps.get_model("alerts", "AlertGroup")
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")

View file

@ -2,11 +2,7 @@ from apps.slack.scenarios import scenario_step
class DeclareIncidentStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
"""
Slack sends a POST request to the backend upon clicking a button with a redirect link to Incident.
This is a dummy step, that is used to prevent raising 'Step is undefined' exception.

View file

@ -16,6 +16,7 @@ from apps.alerts.tasks import custom_button_result
from apps.alerts.utils import render_curl_command
from apps.api.permissions import RBACPermission
from apps.slack.constants import CACHE_UPDATE_INCIDENT_SLACK_MESSAGE_LIFETIME, SLACK_RATE_LIMIT_DELAY
from apps.slack.models import SlackMessage
from apps.slack.scenarios import scenario_step
from apps.slack.scenarios.slack_renderer import AlertGroupLogSlackRenderer
from apps.slack.slack_client import SlackClientWithErrorHandling
@ -41,13 +42,7 @@ logger.setLevel(logging.DEBUG)
class AlertShootingStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
]
def publish_slack_messages(self, slack_team_identity, alert_group, alert, attachments, channel_id, blocks):
SlackMessage = apps.get_model("slack", "SlackMessage")
# channel_id can be None if general log channel for slack_team_identity is not set
if channel_id is None:
logger.info(f"Failed to post message to Slack for alert_group {alert_group.pk} because channel_id is None")
@ -208,7 +203,7 @@ class AlertShootingStep(scenario_step.ScenarioStep):
countdown=1, # delay for message so that the log report is published first
)
def process_scenario(self, slack_user_identity, slack_team_identity, alert, payload=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
pass
@ -218,17 +213,13 @@ class InviteOtherPersonToIncident(
scenario_step.ScenarioStep,
):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "invite to incident"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
User = apps.get_model("user_management", "User")
alert_group = self.get_alert_group_from_slack_message(payload)
alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload)
selected_user = None
if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group):
@ -246,11 +237,11 @@ class InviteOtherPersonToIncident(
if selected_user is not None:
Invitation.invite_user(selected_user, alert_group, self.user)
else:
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
def process_signal(self, log_record):
alert_group = log_record.alert_group
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
class SilenceGroupStep(
@ -259,28 +250,24 @@ class SilenceGroupStep(
scenario_step.ScenarioStep,
):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "silence incident"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
try:
silence_delay = int(payload["actions"][0]["selected_options"][0]["value"])
except KeyError:
silence_delay = int(payload["actions"][0]["selected_option"]["value"])
alert_group = self.get_alert_group_from_slack_message(payload)
alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload)
if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group):
alert_group.silence_by_user(self.user, silence_delay, action_source=ActionSource.SLACK)
def process_signal(self, log_record):
alert_group = log_record.alert_group
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
class UnSilenceGroupStep(
@ -288,23 +275,18 @@ class UnSilenceGroupStep(
IncidentActionsAccessControlMixin,
scenario_step.ScenarioStep,
):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "unsilence incident"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
alert_group = self.get_alert_group_from_slack_message(payload)
alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload)
if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group):
alert_group.un_silence_by_user(self.user, action_source=ActionSource.SLACK)
def process_signal(self, log_record):
alert_group = log_record.alert_group
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
class SelectAttachGroupStep(
@ -312,15 +294,10 @@ class SelectAttachGroupStep(
IncidentActionsAccessControlMixin,
scenario_step.ScenarioStep,
):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "Select Incident for Attaching to"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
AlertGroup = apps.get_model("alerts", "AlertGroup")
value = json.loads(payload["actions"][0]["value"])
alert_group_pk = value.get("alert_group_pk")
@ -468,11 +445,6 @@ class AttachGroupStep(
IncidentActionsAccessControlMixin,
scenario_step.ScenarioStep,
):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "Attach incident"
@ -481,7 +453,7 @@ class AttachGroupStep(
if log_record.type == AlertGroupLogRecord.TYPE_ATTACHED and log_record.alert_group.is_maintenance_incident:
text = f"{log_record.rendered_log_line_action(for_slack=True)}"
self.publish_message_to_thread(alert_group, text=text)
self.alert_group_slack_service.publish_message_to_alert_group_thread(alert_group, text=text)
if log_record.type == AlertGroupLogRecord.TYPE_FAILED_ATTACHMENT:
ephemeral_text = log_record.rendered_log_line_action(for_slack=True)
@ -496,9 +468,9 @@ class AttachGroupStep(
unfurl_links=True,
)
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
# submit selection in modal window
if payload["type"] == scenario_step.PAYLOAD_TYPE_VIEW_SUBMISSION:
@ -516,14 +488,14 @@ class AttachGroupStep(
root_alert_group_pk = int(payload["actions"][0]["selected_option"]["value"])
root_alert_group = AlertGroup.all_objects.get(pk=root_alert_group_pk)
alert_group = self.get_alert_group_from_slack_message(payload)
alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload)
if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group) and self.check_alert_is_unarchived(
slack_team_identity, payload, root_alert_group
):
alert_group.attach_by_user(self.user, root_alert_group, action_source=ActionSource.SLACK)
else:
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
class UnAttachGroupStep(
@ -531,35 +503,25 @@ class UnAttachGroupStep(
IncidentActionsAccessControlMixin,
scenario_step.ScenarioStep,
):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "Unattach incident"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
alert_group = self.get_alert_group_from_slack_message(payload)
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload)
if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group):
alert_group.un_attach_by_user(self.user, action_source=ActionSource.SLACK)
def process_signal(self, log_record):
alert_group = log_record.alert_group
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
class StopInvitationProcess(CheckAlertIsUnarchivedMixin, IncidentActionsAccessControlMixin, scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "stop invitation"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
alert_group = self.get_alert_group_from_slack_message(payload)
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload)
if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group):
return
@ -567,7 +529,7 @@ class StopInvitationProcess(CheckAlertIsUnarchivedMixin, IncidentActionsAccessCo
Invitation.stop_invitation(invitation_pk, self.user)
def process_signal(self, log_record):
self._update_slack_message(log_record.invitation.alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(log_record.invitation.alert_group)
class CustomButtonProcessStep(
@ -575,18 +537,13 @@ class CustomButtonProcessStep(
IncidentActionsAccessControlMixin,
scenario_step.ScenarioStep,
):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
# TODO:
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "click custom button"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
CustomButtom = apps.get_model("alerts", "CustomButton")
alert_group = self.get_alert_group_from_slack_message(payload)
alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload)
if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group):
custom_button_pk = payload["actions"][0]["name"].split("_")[1]
alert_group_pk = payload["actions"][0]["name"].split("_")[2]
@ -595,7 +552,7 @@ class CustomButtonProcessStep(
except CustomButtom.DoesNotExist:
warning_text = "Oops! This button was deleted"
self.open_warning_window(payload, warning_text=warning_text)
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
else:
custom_button_result.apply_async(
args=(
@ -630,7 +587,9 @@ class CustomButtonProcessStep(
attachments = [
{"callback_id": "alert", "text": debug_message},
]
self.publish_message_to_thread(alert_group, attachments=attachments, text=text)
self.alert_group_slack_service.publish_message_to_alert_group_thread(
alert_group, attachments=attachments, text=text
)
class ResolveGroupStep(
@ -638,18 +597,13 @@ class ResolveGroupStep(
IncidentActionsAccessControlMixin,
scenario_step.ScenarioStep,
):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "resolve incident"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
ResolutionNoteModalStep = scenario_step.ScenarioStep.get_step("resolution_note", "ResolutionNoteModalStep")
alert_group = self.get_alert_group_from_slack_message(payload)
alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload)
if not self.check_alert_is_unarchived(slack_team_identity, payload, alert_group):
return
@ -676,7 +630,7 @@ class ResolveGroupStep(
alert_group = log_record.alert_group
if not alert_group.happened_while_maintenance:
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
class UnResolveGroupStep(
@ -684,22 +638,17 @@ class UnResolveGroupStep(
IncidentActionsAccessControlMixin,
scenario_step.ScenarioStep,
):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "unresolve incident"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
alert_group = self.get_alert_group_from_slack_message(payload)
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload)
if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group):
alert_group.un_resolve_by_user(self.user, action_source=ActionSource.SLACK)
def process_signal(self, log_record):
alert_group = log_record.alert_group
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
class AcknowledgeGroupStep(
@ -707,16 +656,11 @@ class AcknowledgeGroupStep(
IncidentActionsAccessControlMixin,
scenario_step.ScenarioStep,
):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "acknowledge incident"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
alert_group = self.get_alert_group_from_slack_message(payload)
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload)
logger.debug(f"process_scenario in AcknowledgeGroupStep for alert_group {alert_group.pk}")
if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group):
alert_group.acknowledge_by_user(self.user, action_source=ActionSource.SLACK)
@ -724,7 +668,7 @@ class AcknowledgeGroupStep(
def process_signal(self, log_record):
alert_group = log_record.alert_group
logger.debug(f"Started process_signal in AcknowledgeGroupStep for alert_group {alert_group.pk}")
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
logger.debug(f"Finished process_signal in AcknowledgeGroupStep for alert_group {alert_group.pk}")
@ -733,16 +677,11 @@ class UnAcknowledgeGroupStep(
IncidentActionsAccessControlMixin,
scenario_step.ScenarioStep,
):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
REQUIRED_PERMISSIONS = [RBACPermission.Permissions.CHATOPS_WRITE]
ACTION_VERBOSE = "unacknowledge incident"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
alert_group = self.get_alert_group_from_slack_message(payload)
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
alert_group = SlackMessage.get_alert_group_from_slack_message_payload(slack_team_identity, payload)
logger.debug(f"process_scenario in UnAcknowledgeGroupStep for alert_group {alert_group.pk}")
if self.check_alert_is_unarchived(slack_team_identity, payload, alert_group):
alert_group.un_acknowledge_by_user(self.user, action_source=ActionSource.SLACK)
@ -782,7 +721,9 @@ class UnAcknowledgeGroupStep(
except SlackAPIException as e:
# post to thread if ack reminder message was deleted in Slack
if e.response["error"] == "message_not_found":
self.publish_message_to_thread(alert_group, attachments=message_attachments, text=text)
self.alert_group_slack_service.publish_message_to_alert_group_thread(
alert_group, attachments=message_attachments, text=text
)
elif e.response["error"] == "account_inactive":
logger.info(
f"Skip unacknowledge slack message for alert_group {alert_group.pk} due to account_inactive"
@ -790,15 +731,17 @@ class UnAcknowledgeGroupStep(
else:
raise
else:
self.publish_message_to_thread(alert_group, attachments=message_attachments, text=text)
self._update_slack_message(alert_group)
self.alert_group_slack_service.publish_message_to_alert_group_thread(
alert_group, attachments=message_attachments, text=text
)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
logger.debug(f"Finished process_signal in UnAcknowledgeGroupStep for alert_group {alert_group.pk}")
class AcknowledgeConfirmationStep(AcknowledgeGroupStep):
ACTION_VERBOSE = "confirm acknowledge status"
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
AlertGroup = apps.get_model("alerts", "AlertGroup")
alert_group_id = payload["actions"][0]["value"].split("_")[1]
alert_group = AlertGroup.all_objects.get(pk=alert_group_id)
@ -926,31 +869,21 @@ class AcknowledgeConfirmationStep(AcknowledgeGroupStep):
alert_group.slack_message.save(update_fields=["ack_reminder_message_ts"])
else:
text = f"This is a reminder that the incident is still acknowledged by {user_verbal}"
self.publish_message_to_thread(alert_group, text=text)
self.alert_group_slack_service.publish_message_to_alert_group_thread(alert_group, text=text)
class WipeGroupStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
ACTION_VERBOSE = "wipe incident"
def process_signal(self, log_record):
alert_group = log_record.alert_group
user_verbal = log_record.author.get_user_verbal_for_team_for_slack()
text = f"Wiped by {user_verbal}"
self.publish_message_to_thread(alert_group, text=text)
self._update_slack_message(alert_group)
self.alert_group_slack_service.publish_message_to_alert_group_thread(alert_group, text=text)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
class DeleteGroupStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
ACTION_VERBOSE = "delete incident"
def process_signal(self, log_record):

View file

@ -10,11 +10,7 @@ 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):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
if payload["event"]["user"] == slack_team_identity.bot_user_id:
channel_id = payload["event"]["channel"]
slack_client = SlackClientWithErrorHandling(slack_team_identity.bot_access_token)

View file

@ -28,7 +28,7 @@ class StartCreateIncidentFromMessage(scenario_step.ScenarioStep):
"incident_create_develop",
]
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
input_id_prefix = _generate_input_id_prefix()
channel_id = payload["channel"]["id"]
@ -67,7 +67,7 @@ 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):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
Alert = apps.get_model("alerts", "Alert")
private_metadata = json.loads(payload["view"]["private_metadata"])
@ -154,7 +154,7 @@ class StartCreateIncidentFromSlashCommand(scenario_step.ScenarioStep):
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):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
input_id_prefix = _generate_input_id_prefix()
try:
@ -188,7 +188,7 @@ 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):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
Alert = apps.get_model("alerts", "Alert")
title = _get_title_from_payload(payload)
@ -264,7 +264,7 @@ class FinishCreateIncidentFromSlashCommand(scenario_step.ScenarioStep):
class OnOrgChange(scenario_step.ScenarioStep):
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
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")
@ -308,7 +308,7 @@ class OnOrgChange(scenario_step.ScenarioStep):
class OnTeamChange(scenario_step.ScenarioStep):
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
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")
@ -355,7 +355,7 @@ 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):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
pass

View file

@ -21,7 +21,7 @@ class NotificationDeliveryStep(scenario_step.ScenarioStep):
log_record.notification_error_code
== UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_SMS_LIMIT_EXCEEDED
):
self.post_message_to_channel(
self._post_message_to_channel(
f"Attempt to send an SMS to {user_verbal_with_mention} has been failed due to a plan limit",
alert_group.slack_message.channel_id,
)
@ -29,7 +29,7 @@ class NotificationDeliveryStep(scenario_step.ScenarioStep):
log_record.notification_error_code
== UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALLS_LIMIT_EXCEEDED
):
self.post_message_to_channel(
self._post_message_to_channel(
f"Attempt to call to {user_verbal_with_mention} has been failed due to a plan limit",
alert_group.slack_message.channel_id,
)
@ -37,7 +37,7 @@ class NotificationDeliveryStep(scenario_step.ScenarioStep):
log_record.notification_error_code
== UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_MAIL_LIMIT_EXCEEDED
):
self.post_message_to_channel(
self._post_message_to_channel(
f"Failed to send email to {user_verbal_with_mention}. Exceeded limit for mails",
alert_group.slack_message.channel_id,
)
@ -46,17 +46,17 @@ class NotificationDeliveryStep(scenario_step.ScenarioStep):
== UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_NUMBER_IS_NOT_VERIFIED
):
if log_record.notification_channel == UserNotificationPolicy.NotificationChannel.SMS:
self.post_message_to_channel(
self._post_message_to_channel(
f"Failed to send an SMS to {user_verbal_with_mention}. Phone number is not verified",
alert_group.slack_message.channel_id,
)
elif log_record.notification_channel == UserNotificationPolicy.NotificationChannel.PHONE_CALL:
self.post_message_to_channel(
self._post_message_to_channel(
f"Failed to call to {user_verbal_with_mention}. Phone number is not verified",
alert_group.slack_message.channel_id,
)
def post_message_to_channel(self, text, channel):
def _post_message_to_channel(self, text, channel):
blocks = [
{
"type": "section",

View file

@ -6,26 +6,16 @@ logger = logging.getLogger(__name__)
class ImOpenStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
]
"""
Empty step to handle event and avoid 500's. In case we need it in the future.
"""
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
logger.info("InOpenStep, doing nothing.")
class AppHomeOpenedStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
]
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
pass

View file

@ -84,7 +84,7 @@ class StartDirectPaging(scenario_step.ScenarioStep):
command_name = [settings.SLACK_DIRECT_PAGING_SLASH_COMMAND]
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
input_id_prefix = _generate_input_id_prefix()
try:
@ -111,7 +111,7 @@ class StartDirectPaging(scenario_step.ScenarioStep):
class FinishDirectPaging(scenario_step.ScenarioStep):
"""Handle page command dialog submit."""
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
title = _get_title_from_payload(payload)
message = _get_message_from_payload(payload)
private_metadata = json.loads(payload["view"]["private_metadata"])
@ -168,7 +168,7 @@ class FinishDirectPaging(scenario_step.ScenarioStep):
class OnPagingOrgChange(scenario_step.ScenarioStep):
"""Reload form with updated organization."""
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
updated_payload = reset_items(payload)
view = render_dialog(slack_user_identity, slack_team_identity, updated_payload)
self._slack_client.api_call(
@ -193,7 +193,7 @@ class OnPagingUserChange(scenario_step.ScenarioStep):
It will perform a user availability check, pushing a new modal for additional confirmation if needed.
"""
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
private_metadata = json.loads(payload["view"]["private_metadata"])
selected_organization = _get_selected_org_from_payload(payload, private_metadata["input_id_prefix"])
selected_team = _get_selected_team_from_payload(payload, private_metadata["input_id_prefix"])
@ -250,7 +250,7 @@ class OnPagingItemActionChange(scenario_step.ScenarioStep):
class OnPagingConfirmUserChange(scenario_step.ScenarioStep):
"""Confirm user selection despite not being available."""
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
metadata = json.loads(payload["view"]["private_metadata"])
# recreate original view state and metadata
@ -579,10 +579,10 @@ def _get_users_select(organization, team, input_id_prefix):
"action_id": OnPagingUserChange.routing_uid(),
},
}
if len(user_options) > scenario_step.MAX_STATIC_SELECT_OPTIONS:
MAX_STATIC_SELECT_OPTIONS = 100
if len(user_options) > MAX_STATIC_SELECT_OPTIONS:
# paginate user options in groups
max_length = scenario_step.MAX_STATIC_SELECT_OPTIONS
max_length = MAX_STATIC_SELECT_OPTIONS
chunks = [user_options[x : x + max_length] for x in range(0, len(user_options), max_length)]
option_groups = [
{

View file

@ -3,13 +3,7 @@ from apps.slack.scenarios import scenario_step
class ProfileUpdateStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
]
# Avoid logging this step to prevent collecting sensitive data of our customers
need_to_be_logged = False
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
"""
Triggered by action: Any update in Slack Profile.
Dangerous because it's often triggered by internal client's company systems.

View file

@ -21,11 +21,8 @@ class AddToResolutionNoteStep(CheckAlertIsUnarchivedMixin, scenario_step.Scenari
"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):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
SlackMessage = apps.get_model("slack", "SlackMessage")
ResolutionNoteSlackMessage = apps.get_model("alerts", "ResolutionNoteSlackMessage")
ResolutionNote = apps.get_model("alerts", "ResolutionNote")
@ -153,7 +150,7 @@ class AddToResolutionNoteStep(CheckAlertIsUnarchivedMixin, scenario_step.Scenari
except SlackAPIException:
pass
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
else:
warning_text = "Unable to add this message to resolution note."
self.open_warning_window(payload, warning_text)
@ -329,7 +326,7 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep):
def update_alert_group_resolution_note_button(self, alert_group):
if alert_group.slack_message is not None:
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
def add_resolution_note_reaction(self, slack_thread_message):
try:
@ -376,15 +373,10 @@ class UpdateResolutionNoteStep(scenario_step.ScenarioStep):
class ResolutionNoteModalStep(CheckAlertIsUnarchivedMixin, scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
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):
def process_scenario(self, slack_user_identity, slack_team_identity, payload, data=None):
AlertGroup = apps.get_model("alerts", "AlertGroup")
value = data or json.loads(payload["actions"][0]["value"])
resolution_note_window_action = value.get("resolution_note_window_action", "") or value.get("action_value", "")
@ -666,12 +658,7 @@ class ReadEditPostmortemStep(ResolutionNoteModalStep):
class AddRemoveThreadMessageStep(UpdateResolutionNoteStep, scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_INCIDENT_ROUTINE,
]
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
AlertGroup = apps.get_model("alerts", "AlertGroup")
ResolutionNoteSlackMessage = apps.get_model("alerts", "ResolutionNoteSlackMessage")
ResolutionNote = apps.get_model("alerts", "ResolutionNote")

View file

@ -1,18 +1,8 @@
import importlib
import json
import logging
from django.apps import apps
from django.core.cache import cache
from apps.slack.constants import SLACK_RATE_LIMIT_DELAY
from apps.slack.alert_group_slack_service import AlertGroupSlackService
from apps.slack.slack_client import SlackClientWithErrorHandling
from apps.slack.slack_client.exceptions import (
SlackAPIChannelArchivedException,
SlackAPIException,
SlackAPIRateLimitException,
SlackAPITokenException,
)
logger = logging.getLogger(__name__)
@ -62,81 +52,22 @@ PAYLOAD_TYPE_MESSAGE_ACTION = "message_action"
THREAD_MESSAGE_SUBTYPE = "bot_message"
MAX_STATIC_SELECT_OPTIONS = 100
class ScenarioStep(object):
# Is a delay to prevent intermediate activity by system in case user is doing some multi-step action.
# For example if user wants to unack and ack we don't need to launch escalation right after unack.
CROSS_ACTION_DELAY = 10
SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID = "SELECT_ORGANIZATION_AND_ROUTE"
need_to_be_logged = True
random_prefix_for_routing = ""
# Some blocks are sending context via action_id, which is limited by 255 chars
TAG_ONBOARDING = "onboarding"
TAG_DASHBOARD = "dashboard"
TAG_SUBSCRIPTION = "subscription"
TAG_REPORTING = "reporting"
TAG_TEAM_SETTINGS = "team_settings"
TAG_TRIGGERED_BY_SYSTEM = "triggered_by_system"
TAG_INCIDENT_ROUTINE = "incident_routine"
TAG_INCIDENT_MANAGEMENT = "incident_management"
TAG_ON_CALL_SCHEDULES = "on_call_schedules"
tags = []
def __init__(self, slack_team_identity, organization=None, user=None):
self._slack_client = SlackClientWithErrorHandling(slack_team_identity.bot_access_token)
self.slack_team_identity = slack_team_identity
self.organization = organization
self.user = user
cache_tag = "step_tags_populated_{}".format(self.routing_uid())
self.alert_group_slack_service = AlertGroupSlackService(slack_team_identity, self._slack_client)
if cache.get(cache_tag) is None:
cache.set(cache_tag, 1, 180)
def dispatch(self, user, team, payload, action=None):
return self.process_scenario(user, team, payload, action)
def process_scenario(self, user, team, payload, action=None):
def process_scenario(self, user, team, payload):
pass
def ts(self, payload):
if "message_ts" in payload:
ts = payload["message_ts"]
elif (
"view" in payload
and "private_metadata" in payload["view"]
and payload["view"]["private_metadata"]
and "ts" in json.loads(payload["view"]["private_metadata"])
):
ts = json.loads(payload["view"]["private_metadata"])["ts"]
elif "container" in payload and "message_ts" in payload["container"]:
ts = payload["container"]["message_ts"]
elif "state" in payload and "message_ts" in json.loads(payload["state"]):
ts = json.loads(payload["state"])["message_ts"]
else:
ts = "random"
return ts
def channel(self, user, payload):
if "channel" in payload and "id" in payload["channel"]:
channel = payload["channel"]["id"]
else:
channel = user.im_channel_id
return channel
@classmethod
def routing_uid(cls):
return cls.random_prefix_for_routing + cls.__name__
return cls.__name__
@classmethod
def get_step(cls, scenario, step):
@ -152,15 +83,6 @@ class ScenarioStep(object):
except ImportError as e:
raise Exception("Check import spelling! Scenario: {}, Step:{}, Error: {}".format(scenario, step, e))
def process_scenario_from_other_step(
self, slack_user_identity, slack_team_identity, payload, step_class, action=None, kwargs={}
):
"""
Allows to trigger other step from current step
"""
step = step_class(slack_team_identity)
step.process_scenario(slack_user_identity, slack_team_identity, payload, action=action, **kwargs)
def open_warning_window(self, payload, warning_text, title=None):
if title is None:
title = ":warning: Warning"
@ -191,212 +113,3 @@ class ScenarioStep(object):
trigger_id=payload["trigger_id"],
view=view,
)
def get_alert_group_from_slack_message(self, payload):
SlackMessage = apps.get_model("slack", "SlackMessage")
message_ts = payload.get("message_ts") or payload["container"]["message_ts"] # interactive message or block
channel_id = payload["channel"]["id"]
try:
slack_message = SlackMessage.objects.get(
slack_id=message_ts,
_slack_team_identity=self.slack_team_identity,
channel_id=channel_id,
)
alert_group = slack_message.get_alert_group()
except SlackMessage.DoesNotExist as e:
print(
f"Tried to get SlackMessage from message_ts:"
f"Slack Team Identity pk: {self.slack_team_identity.pk},"
f"Message ts: {message_ts}"
)
raise e
except SlackMessage.alert.RelatedObjectDoesNotExist as e:
print(
f"Tried to get Alert Group from SlackMessage:"
f"Slack Team Identity pk: {self.slack_team_identity.pk},"
f"SlackMessage pk: {slack_message.pk}"
)
raise e
return alert_group
def _update_slack_message(self, alert_group):
logger.info(f"Started _update_slack_message for alert_group {alert_group.pk}")
SlackMessage = apps.get_model("slack", "SlackMessage")
AlertReceiveChannel = apps.get_model("alerts", "AlertReceiveChannel")
slack_message = alert_group.slack_message
attachments = alert_group.render_slack_attachments()
blocks = alert_group.render_slack_blocks()
logger.info(f"Update message for alert_group {alert_group.pk}")
try:
self._slack_client.api_call(
"chat.update",
channel=slack_message.channel_id,
ts=slack_message.slack_id,
attachments=attachments,
blocks=blocks,
)
logger.info(f"Message has been updated for alert_group {alert_group.pk}")
except SlackAPIRateLimitException as e:
if alert_group.channel.integration != AlertReceiveChannel.INTEGRATION_MAINTENANCE:
if not alert_group.channel.is_rate_limited_in_slack:
delay = e.response.get("rate_limit_delay") or SLACK_RATE_LIMIT_DELAY
alert_group.channel.start_send_rate_limit_message_task(delay)
logger.info(
f"Message has not been updated for alert_group {alert_group.pk} due to slack rate limit."
)
else:
raise e
except SlackAPIException as e:
if e.response["error"] == "message_not_found":
logger.info(f"message_not_found for alert_group {alert_group.pk}, trying to post new message")
result = self._slack_client.api_call(
"chat.postMessage", channel=slack_message.channel_id, attachments=attachments, blocks=blocks
)
slack_message_updated = SlackMessage(
slack_id=result["ts"],
organization=slack_message.organization,
_slack_team_identity=slack_message.slack_team_identity,
channel_id=slack_message.channel_id,
alert_group=alert_group,
)
slack_message_updated.save()
alert_group.slack_message = slack_message_updated
alert_group.save(update_fields=["slack_message"])
logger.info(f"Message has been posted for alert_group {alert_group.pk}")
elif e.response["error"] == "is_inactive": # deleted channel error
logger.info(f"Skip updating slack message for alert_group {alert_group.pk} due to is_inactive")
elif e.response["error"] == "account_inactive":
logger.info(f"Skip updating slack message for alert_group {alert_group.pk} due to account_inactive")
elif e.response["error"] == "channel_not_found":
logger.info(f"Skip updating slack message for alert_group {alert_group.pk} due to channel_not_found")
else:
raise e
logger.info(f"Finished _update_slack_message for alert_group {alert_group.pk}")
def publish_message_to_thread(self, alert_group, attachments=[], mrkdwn=True, unfurl_links=True, text=None):
# TODO: refactor checking the possibility of sending message to slack
# do not try to post message to slack if integration is rate limited
if alert_group.channel.is_rate_limited_in_slack:
return
SlackMessage = apps.get_model("slack", "SlackMessage")
slack_message = alert_group.get_slack_message()
channel_id = slack_message.channel_id
try:
result = self._slack_client.api_call(
"chat.postMessage",
channel=channel_id,
text=text,
attachments=attachments,
thread_ts=slack_message.slack_id,
mrkdwn=mrkdwn,
unfurl_links=unfurl_links,
)
except SlackAPITokenException as e:
logger.warning(
f"Unable to post message to thread in slack. "
f"Slack team identity pk: {self.slack_team_identity.pk}.\n"
f"{e}"
)
except SlackAPIChannelArchivedException:
logger.warning(
f"Unable to post message to thread in slack. "
f"Slack team identity pk: {self.slack_team_identity.pk}.\n"
f"Reason: 'is_archived'"
)
except SlackAPIException as e:
if e.response["error"] == "channel_not_found": # channel was deleted
logger.warning(
f"Unable to post message to thread in slack. "
f"Slack team identity pk: {self.slack_team_identity.pk}.\n"
f"Reason: 'channel_not_found'"
)
elif e.response["error"] == "invalid_auth":
logger.warning(
f"Unable to post message to thread in slack. "
f"Slack team identity pk: {self.slack_team_identity.pk}.\n"
f"Reason: 'invalid_auth'"
)
else:
raise e
else:
SlackMessage(
slack_id=result["ts"],
organization=alert_group.channel.organization,
_slack_team_identity=self.slack_team_identity,
channel_id=channel_id,
alert_group=alert_group,
).save()
def get_select_user_element(
self, action_id, multi_select=False, initial_user=None, initial_users_list=None, text=None
):
if not text:
text = f"Select User{'' if not multi_select else 's'}"
element = {
"action_id": action_id,
"type": "static_select" if not multi_select else "multi_static_select",
"placeholder": {
"type": "plain_text",
"text": text,
"emoji": True,
},
}
users = self.organization.users.all().select_related("slack_user_identity")
users_count = users.count()
options = []
for user in users:
user_verbal = f"{user.get_user_verbal_for_team_for_slack()}"
if len(user_verbal) > 75:
user_verbal = user_verbal[:72] + "..."
option = {"text": {"type": "plain_text", "text": user_verbal}, "value": json.dumps({"user_id": user.pk})}
options.append(option)
if users_count > MAX_STATIC_SELECT_OPTIONS:
option_groups = []
option_groups_chunks = [
options[x : x + MAX_STATIC_SELECT_OPTIONS] for x in range(0, len(options), MAX_STATIC_SELECT_OPTIONS)
]
for option_group in option_groups_chunks:
option_group = {"label": {"type": "plain_text", "text": " "}, "options": option_group}
option_groups.append(option_group)
element["option_groups"] = option_groups
elif users_count == 0: # strange case when there are no users to select
option = {
"text": {"type": "plain_text", "text": "No users to select"},
"value": json.dumps({"user_id": None}),
}
options.append(option)
element["options"] = options
return element
else:
element["options"] = options
# add initial option
if multi_select and initial_users_list:
if users_count <= MAX_STATIC_SELECT_OPTIONS:
initial_options = []
for user in users:
user_verbal = f"{user.get_user_verbal_for_team_for_slack()}"
option = {
"text": {"type": "plain_text", "text": user_verbal},
"value": json.dumps({"user_id": user.pk}),
}
initial_options.append(option)
element["initial_options"] = initial_options
elif not multi_select and initial_user:
user_verbal = f"{initial_user.get_user_verbal_for_team_for_slack()}"
initial_option = {
"text": {"type": "plain_text", "text": user_verbal},
"value": json.dumps({"user_id": initial_user.pk}),
}
element["initial_option"] = initial_option
return element

View file

@ -10,14 +10,12 @@ from common.insight_log import EntityEvent, write_resource_insight_log
class EditScheduleShiftNotifyStep(scenario_step.ScenarioStep):
tags = [scenario_step.ScenarioStep.TAG_ON_CALL_SCHEDULES]
notify_empty_oncall_options = {choice[0]: choice[1] for choice in OnCallSchedule.NotifyEmptyOnCall.choices}
notify_oncall_shift_freq_options = {choice[0]: choice[1] for choice in OnCallSchedule.NotifyOnCallShiftFreq.choices}
mention_oncall_start_options = {1: "Mention person in slack", 0: "Inform in channel without mention"}
mention_oncall_next_options = {1: "Mention person in slack", 0: "Inform in channel without mention"}
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
if payload["actions"][0].get("value", None) and payload["actions"][0]["value"].startswith("edit"):
self.open_settings_modal(payload)
elif payload["actions"][0].get("type", None) and payload["actions"][0]["type"] == "static_select":

View file

@ -8,14 +8,7 @@ from apps.slack.tasks import clean_slack_channel_leftovers
class SlackChannelCreatedOrRenamedEventStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
]
# Avoid logging this step to prevent collecting sensitive data of our customers
need_to_be_logged = False
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
"""
Triggered by action: Create or rename channel
"""
@ -35,14 +28,7 @@ class SlackChannelCreatedOrRenamedEventStep(scenario_step.ScenarioStep):
class SlackChannelDeletedEventStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
]
# Avoid logging this step to prevent collecting sensitive data of our customers
need_to_be_logged = False
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
"""
Triggered by action: Delete channel
"""
@ -59,14 +45,7 @@ class SlackChannelDeletedEventStep(scenario_step.ScenarioStep):
class SlackChannelArchivedEventStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
]
# Avoid logging this step to prevent collecting sensitive data of our customers
need_to_be_logged = False
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
"""
Triggered by action: Archive channel
"""
@ -82,14 +61,7 @@ class SlackChannelArchivedEventStep(scenario_step.ScenarioStep):
class SlackChannelUnArchivedEventStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
]
# Avoid logging this step to prevent collecting sensitive data of our customers
need_to_be_logged = False
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
"""
Triggered by action: UnArchive channel
"""

View file

@ -11,14 +11,7 @@ logger.setLevel(logging.DEBUG)
class SlackChannelMessageEventStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
]
# Avoid logging this step to prevent collecting sensitive data of our customers
need_to_be_logged = False
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
"""
Triggered by action: Any new message in channel.
Dangerous because it's often triggered by internal client's company systems.
@ -149,7 +142,7 @@ class SlackChannelMessageEventStep(scenario_step.ScenarioStep):
else:
alert_group = slack_thread_message.alert_group
slack_thread_message.delete()
self._update_slack_message(alert_group)
self.alert_group_slack_service.update_alert_group_slack_message(alert_group)
def create_alert_for_slack_channel_integration_if_needed(self, payload):
if "subtype" in payload["event"] and payload["event"]["subtype"] != scenario_step.EVENT_SUBTYPE_FILE_SHARE:

View file

@ -5,14 +5,7 @@ from apps.slack.scenarios import scenario_step
class SlackUserGroupEventStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
]
# Avoid logging this step to prevent collecting sensitive data of our customers
need_to_be_logged = False
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
"""
Triggered by action: creation user groups or changes in user groups except its members.
"""
@ -38,14 +31,7 @@ class SlackUserGroupEventStep(scenario_step.ScenarioStep):
class SlackUserGroupMembersChangedEventStep(scenario_step.ScenarioStep):
tags = [
scenario_step.ScenarioStep.TAG_TRIGGERED_BY_SYSTEM,
]
# Avoid logging this step to prevent collecting sensitive data of our customers
need_to_be_logged = False
def process_scenario(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
"""
Triggered by action: changed members in user group.
"""

View file

@ -10,9 +10,9 @@ class AccessControl(ABC):
REQUIRED_PERMISSIONS = []
ACTION_VERBOSE = ""
def dispatch(self, slack_user_identity, slack_team_identity, payload, action=None):
def process_scenario(self, slack_user_identity, slack_team_identity, payload):
if self.check_membership():
return super().dispatch(slack_user_identity, slack_team_identity, payload, action=None)
return super().process_scenario(slack_user_identity, slack_team_identity, payload)
else:
self.send_denied_message(payload)

View file

@ -9,8 +9,8 @@ from django.core.cache import cache
from django.utils import timezone
from apps.alerts.tasks.compare_escalations import compare_escalations
from apps.slack.alert_group_slack_service import AlertGroupSlackService
from apps.slack.constants import CACHE_UPDATE_INCIDENT_SLACK_MESSAGE_LIFETIME, SLACK_BOT_ID
from apps.slack.scenarios.escalation_delivery import EscalationDeliveryStep
from apps.slack.scenarios.scenario_step import ScenarioStep
from apps.slack.slack_client import SlackClientWithErrorHandling
from apps.slack.slack_client.exceptions import SlackAPIException, SlackAPITokenException
@ -55,7 +55,7 @@ def update_incident_slack_message(slack_team_identity_pk, alert_group_pk):
return "Skip message update in Slack due to rate limit"
if alert_group.slack_message is None:
return "Skip message update in Slack due to absence of slack message"
ScenarioStep(slack_team_identity, alert_group.channel.organization)._update_slack_message(alert_group)
AlertGroupSlackService(slack_team_identity).update_alert_group_slack_message(alert_group)
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True)
@ -98,9 +98,7 @@ def check_slack_message_exists_before_post_message_to_thread(
slack_message = alert_group.get_slack_message()
if slack_message is not None:
EscalationDeliveryStep(slack_team_identity, alert_group.channel.organization).publish_message_to_thread(
alert_group, text=text
)
AlertGroupSlackService(slack_team_identity).publish_message_to_alert_group_thread(alert_group, text=text)
# check how much time has passed since alert group was created
# to prevent eternal loop of restarting check_slack_message_before_post_message_to_thread
@ -240,7 +238,7 @@ def send_message_to_thread_if_bot_not_in_channel(alert_group_pk, slack_team_iden
members = slack_team_identity.get_conversation_members(sc, channel_id)
if bot_user_id not in members:
text = f"Please invite <@{bot_user_id}> to this channel to make all features " f"available :wink:"
ScenarioStep(slack_team_identity).publish_message_to_thread(alert_group, text=text)
AlertGroupSlackService(slack_team_identity, sc).publish_message_to_alert_group_thread(alert_group, text=text)
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True, max_retries=1)

View file

@ -56,7 +56,7 @@ from apps.slack.tasks import clean_slack_integration_leftovers, unpopulate_slack
from common.insight_log import ChatOpsEvent, ChatOpsType, write_chatops_insight_log
from common.oncall_gateway import delete_slack_connector_async
from .models import SlackActionRecord, SlackMessage, SlackTeamIdentity, SlackUserIdentity
from .models import SlackMessage, SlackTeamIdentity, SlackUserIdentity
SCENARIOS_ROUTES = [] # Add all other routes here
SCENARIOS_ROUTES.extend(ONBOARDING_STEPS_ROUTING)
@ -75,6 +75,8 @@ SCENARIOS_ROUTES.extend(DECLARE_INCIDENT_ROUTING)
logger = logging.getLogger(__name__)
SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID = "SELECT_ORGANIZATION_AND_ROUTE"
class StopAnalyticsReporting(APIView):
def get(self, request):
@ -289,8 +291,6 @@ class SlackEventApiEndpointView(APIView):
self._open_warning_for_unconnected_user(sc, payload)
return Response(status=200)
action_record = SlackActionRecord(user=user, organization=organization, payload=payload)
# Capture cases when we expect stateful message from user
if not step_was_found and "type" in payload and payload["type"] == PAYLOAD_TYPE_EVENT_CALLBACK:
# Message event is from channel
@ -313,98 +313,86 @@ class SlackEventApiEndpointView(APIView):
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.dispatch(slack_user_identity, slack_team_identity, payload)
step.process_scenario(slack_user_identity, slack_team_identity, payload)
step_was_found = True
# We don't do anything on app mention, but we doesn't want to unsubscribe from this event yet.
if payload["event"]["type"] == EVENT_TYPE_APP_MENTION:
logger.info(f"Received event of type {EVENT_TYPE_APP_MENTION} from slack. Skipping.")
return Response(status=200)
# Routing to Steps based on routing rules
try:
if not step_was_found:
for route in SCENARIOS_ROUTES:
# Slash commands have to "type"
if "command" in payload and route["payload_type"] == PAYLOAD_TYPE_SLASH_COMMAND:
if payload["command"] in route["command_name"]:
Step = route["step"]
action_record.step = Step.routing_uid()
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.dispatch(slack_user_identity, slack_team_identity, payload)
step_was_found = True
if not step_was_found:
for route in SCENARIOS_ROUTES:
# Slash commands have to "type"
if "command" in payload and route["payload_type"] == PAYLOAD_TYPE_SLASH_COMMAND:
if payload["command"] in route["command_name"]:
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.process_scenario(slack_user_identity, slack_team_identity, payload)
step_was_found = True
if "type" in payload and payload["type"] == route["payload_type"]:
if payload["type"] == PAYLOAD_TYPE_EVENT_CALLBACK:
if payload["event"]["type"] == route["event_type"]:
# event_name is used for stateful
if "event_name" not in route:
if "type" in payload and payload["type"] == route["payload_type"]:
if payload["type"] == PAYLOAD_TYPE_EVENT_CALLBACK:
if payload["event"]["type"] == route["event_type"]:
# event_name is used for stateful
if "event_name" not in route:
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.process_scenario(slack_user_identity, slack_team_identity, payload)
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_INTERACTIVE_MESSAGE:
for action in payload["actions"]:
if action["type"] == route["action_type"]:
# Action name may also contain action arguments.
# So only beginning is used for routing.
if action["name"].startswith(route["action_name"]):
Step = route["step"]
action_record.step = Step.routing_uid()
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.dispatch(slack_user_identity, slack_team_identity, payload)
result = step.process_scenario(slack_user_identity, slack_team_identity, payload)
if result is not None:
return result
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_INTERACTIVE_MESSAGE:
for action in payload["actions"]:
if action["type"] == route["action_type"]:
# Action name may also contain action arguments.
# So only beginning is used for routing.
if action["name"].startswith(route["action_name"]):
Step = route["step"]
action_record.step = Step.routing_uid()
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
result = step.dispatch(slack_user_identity, slack_team_identity, payload)
if result is not None:
return result
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_BLOCK_ACTIONS:
for action in payload["actions"]:
if action["type"] == route["block_action_type"]:
if action["action_id"].startswith(route["block_action_id"]):
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.process_scenario(slack_user_identity, slack_team_identity, payload)
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_BLOCK_ACTIONS:
for action in payload["actions"]:
if action["type"] == route["block_action_type"]:
if action["action_id"].startswith(route["block_action_id"]):
Step = route["step"]
action_record.step = Step.routing_uid()
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.dispatch(slack_user_identity, slack_team_identity, payload)
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_DIALOG_SUBMISSION:
if payload["callback_id"] == route["dialog_callback_id"]:
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
result = step.process_scenario(slack_user_identity, slack_team_identity, payload)
if result is not None:
return result
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_DIALOG_SUBMISSION:
if payload["callback_id"] == route["dialog_callback_id"]:
Step = route["step"]
action_record.step = Step.routing_uid()
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
result = step.dispatch(slack_user_identity, slack_team_identity, payload)
if result is not None:
return result
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_VIEW_SUBMISSION:
if payload["view"]["callback_id"].startswith(route["view_callback_id"]):
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
result = step.process_scenario(slack_user_identity, slack_team_identity, payload)
if result is not None:
return result
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_VIEW_SUBMISSION:
if payload["view"]["callback_id"].startswith(route["view_callback_id"]):
Step = route["step"]
action_record.step = Step.routing_uid()
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
result = step.dispatch(slack_user_identity, slack_team_identity, payload)
if result is not None:
return result
step_was_found = True
if payload["type"] == PAYLOAD_TYPE_MESSAGE_ACTION:
if payload["callback_id"] in route["message_action_callback_id"]:
Step = route["step"]
action_record.step = Step.routing_uid()
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.dispatch(slack_user_identity, slack_team_identity, payload)
step_was_found = True
finally:
if Step is not None and Step.need_to_be_logged and organization:
action_record.save()
if payload["type"] == PAYLOAD_TYPE_MESSAGE_ACTION:
if payload["callback_id"] in route["message_action_callback_id"]:
Step = route["step"]
logger.info("Routing to {}".format(Step))
step = Step(slack_team_identity, organization, user)
step.process_scenario(slack_user_identity, slack_team_identity, payload)
step_was_found = True
if not step_was_found:
raise Exception("Step is undefined" + str(payload))
@ -440,12 +428,10 @@ class SlackEventApiEndpointView(APIView):
if private_metadata and "organization_id" in private_metadata:
organization_id = json.loads(private_metadata).get("organization_id")
# steps with organization selection in view (e.g. slash commands)
elif ScenarioStep.SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID in payload["view"].get("state", {}).get(
"values", {}
):
elif SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID in payload["view"].get("state", {}).get("values", {}):
payload_values = payload["view"]["state"]["values"]
selected_value = payload_values[ScenarioStep.SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID][
ScenarioStep.SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID
selected_value = payload_values[SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID][
SELECT_ORGANIZATION_AND_ROUTE_BLOCK_ID
]["selected_option"]["value"]
organization_id = int(selected_value.split("-")[0])
if organization_id:

View file

@ -0,0 +1,56 @@
from rest_framework.throttling import SimpleRateThrottle
class CustomRateScopedThrottler(SimpleRateThrottle):
"""
Abstract class to create throttlers with custom amount of seconds and custom scope.
The unique cache key will be generated by concatenating the
user id of the request, and the scope from get_scope() method.
Should not be used directly.
"""
def __init__(self):
self.scope = self.get_scope()
self.num_requests, self.duration = self.get_throttle_limits()
def get_throttle_limits(self):
"""
:return tuple requests/seconds
"""
raise NotImplementedError
def get_scope(self):
"""
:return ratelimit scope
"""
raise NotImplementedError
def allow_request(self, request, view):
"""
Overriden allow_request method.
The difference is that overriden method doesn't check rate property.
"""
self.key = self.get_cache_key(request, view)
if self.key is None:
return True
self.history = self.cache.get(self.key, [])
self.now = self.timer()
# Drop any requests from the history which have now passed the
# throttle duration
while self.history and self.history[-1] <= self.now - self.duration:
self.history.pop()
if len(self.history) >= self.num_requests:
return self.throttle_failure()
return self.throttle_success()
def get_cache_key(self, request, view):
if request.user.is_authenticated:
ident = request.user.pk
else:
ident = self.get_ident(request)
return self.cache_format % {"scope": self.scope, "ident": ident}

View file

@ -7,6 +7,7 @@ from django.utils import dateparse, timezone
from icalendar import Calendar
from rest_framework import serializers
from apps.schedules.ical_utils import fetch_ical_file
from common.api_helpers.exceptions import BadRequest
from common.timezones import raise_exception_if_not_valid_timezone
@ -49,7 +50,7 @@ def validate_ical_url(url):
if settings.BASE_URL in url:
raise serializers.ValidationError("Potential self-reference")
try:
ical_file = requests.get(url).text
ical_file = fetch_ical_file(url)
Calendar.from_ical(ical_file)
except requests.exceptions.RequestException:
raise serializers.ValidationError("Ical download failed")

View file

@ -48,3 +48,4 @@ opentelemetry-instrumentation-wsgi==0.36b0
opentelemetry-exporter-otlp-proto-grpc==1.15.0
pyroscope-io==0.8.1
django-dbconn-retry==0.1.7
drf-recaptcha==2.2.2

View file

@ -222,6 +222,7 @@ INSTALLED_APPS = [
"django_migration_linter",
"fcm_django",
"django_dbconn_retry",
"drf_recaptcha",
]
REST_FRAMEWORK = {
@ -655,6 +656,18 @@ if OSS_INSTALLATION:
"args": (),
} # noqa
# google recaptcha is disabled by default
#
# without setting DRF_RECAPTCHA_TESTING, drf_recaptcha complains with
# AttributeError: 'Settings' object has no attribute 'DRF_RECAPTCHA_SECRET_KEY'
#
# Set DRF_RECAPTCHA_TESTING=True in settings, no request to Google, no warnings
# DRF_RECAPTCHA_SECRET_KEY is not required, set returning verification result in setting below.
DRF_RECAPTCHA_SECRET_KEY = os.environ.get("DRF_RECAPTCHA_SECRET_KEY", default=None)
DRF_RECAPTCHA_DEFAULT_V3_SCORE = 0.5
DRF_RECAPTCHA_TESTING = True
DRF_RECAPTCHA_TESTING_PASS = True
MIGRATION_LINTER_OPTIONS = {"exclude_apps": ["social_django", "silk", "fcm_django"]}
# Run migrations linter on each `python manage.py makemigrations`
MIGRATION_LINTER_OVERRIDE_MAKEMIGRATIONS = True

View file

@ -12,6 +12,7 @@ import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import { openErrorNotification } from 'utils';
import { isUserActionAllowed, UserAction, UserActions } from 'utils/authorization';
import { reCAPTCHA_site_key } from 'utils/consts';
import styles from './PhoneVerification.module.css';
@ -99,26 +100,32 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => {
openErrorNotification(error.response.data);
});
} else {
await userStore.updateUser({
pk: userPk,
email: user.email,
unverified_phone_number: phone,
window.grecaptcha.ready(function () {
window.grecaptcha
.execute(reCAPTCHA_site_key, { action: 'mobile_verification_code' })
.then(async function (token) {
await userStore.updateUser({
pk: userPk,
email: user.email,
unverified_phone_number: phone,
});
userStore
.fetchVerificationCode(userPk, token)
.then(() => {
setState({ isCodeSent: true });
if (codeInputRef.current) {
codeInputRef.current.focus();
}
})
.catch(() => {
openErrorNotification(
'Grafana OnCall is unable to verify your phone number due to incorrect number or verification service being unavailable.'
);
});
});
});
userStore
.fetchVerificationCode(userPk)
.then(() => {
setState({ isCodeSent: true });
if (codeInputRef.current) {
codeInputRef.current.focus();
}
})
.catch(() => {
openErrorNotification(
'Grafana OnCall is unable to verify your phone number due to incorrect number or verification service being unavailable.'
);
});
}
}, [
code,
@ -200,7 +207,6 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => {
/>
</WithPermissionControl>
</Field>
{!user.verified_phone_number && (
<Input
ref={codeInputRef}
@ -211,7 +217,20 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => {
className={cx('phone__field')}
/>
)}
<HorizontalGroup spacing="xs">
<Icon name="info-circle" />
<Text type="secondary">
This site is protected by reCAPTCHA and the Google{' '}
<a target="_blank" rel="noreferrer" href="https://policies.google.com/privacy">
<Text type="link">Privacy Policy</Text>
</a>{' '}
and{' '}
<a target="_blank" rel="noreferrer" href="https://policies.google.com/terms">
<Text type="link">Terms of Service </Text>
</a>{' '}
apply.
</Text>
</HorizontalGroup>
{showToggle && (
<div className={cx('switch')}>
<div className={cx('switch__icon')}>
@ -315,25 +334,25 @@ function PhoneVerificationButtonsGroup({
)}
{user.verified_phone_number && (
<WithPermissionControl userAction={action}>
<Button
disabled={!user?.verified_phone_number || !isTwilioConfigured || isTestCallInProgress}
onClick={handleMakeTestCallClick}
>
{isTestCallInProgress ? 'Making Test Call...' : 'Make Test Call'}
</Button>
</WithPermissionControl>
<>
<WithPermissionControl userAction={action}>
<Button
disabled={!user?.verified_phone_number || !isTwilioConfigured || isTestCallInProgress}
onClick={handleMakeTestCallClick}
>
{isTestCallInProgress ? 'Making Test Call...' : 'Make Test Call'}
</Button>
</WithPermissionControl>
<Tooltip content={'Click "Make Test Call" to save a phone number and add it to DnD exceptions.'}>
<Icon
name="info-circle"
style={{
marginLeft: '10px',
}}
/>
</Tooltip>
</>
)}
<Tooltip content={'Click "Make Test Call" to save a phone number and add it to DnD exceptions.'}>
<Icon
name="info-circle"
style={{
marginLeft: '10px',
color: '#1890ff',
}}
/>
</Tooltip>
</HorizontalGroup>
);
}

View file

@ -87,4 +87,5 @@ interface RenderForWeb {
message: any;
title: any;
image_url: string;
source_link: string;
}

View file

@ -233,9 +233,10 @@ export class UserStore extends BaseStore {
}
@action
async fetchVerificationCode(userPk: User['pk']) {
async fetchVerificationCode(userPk: User['pk'], recaptchaToken: string) {
await makeRequest(`/users/${userPk}/get_verification_code/`, {
method: 'GET',
headers: { 'X-OnCall-Recaptcha': recaptchaToken },
});
}

View file

@ -32,12 +32,15 @@ interface RequestConfig {
data?: any;
withCredentials?: boolean;
validateStatus?: (status: number) => boolean;
headers?: {
[key: string]: string | number;
};
}
export const isNetworkError = axios.isAxiosError;
export const makeRequest = async <RT = any>(path: string, config: RequestConfig) => {
const { method = 'GET', params, data, validateStatus } = config;
const { method = 'GET', params, data, validateStatus, headers } = config;
const url = `${API_PROXY_PREFIX}${API_PATH_PREFIX}${path}`;
const otel = FaroHelper.faro?.api?.getOTEL();
@ -63,6 +66,7 @@ export const makeRequest = async <RT = any>(path: string, config: RequestConfig)
params,
data,
validateStatus,
headers,
})
.then((response) => {
FaroHelper.faro.api.pushEvent('Request completed', { url });
@ -86,6 +90,7 @@ export const makeRequest = async <RT = any>(path: string, config: RequestConfig)
params,
data,
validateStatus,
headers,
})
.then((response) => {
FaroHelper.faro?.api.pushEvent('Request completed', { url });

View file

@ -1,6 +1,7 @@
import React from 'react';
import { Button, HorizontalGroup, IconButton, Tooltip, VerticalGroup } from '@grafana/ui';
import cn from 'classnames/bind';
import Avatar from 'components/Avatar/Avatar';
import { MatchMediaTooltip } from 'components/MatchMediaTooltip/MatchMediaTooltip';
@ -16,11 +17,15 @@ import { move } from 'state/helpers';
import { UserActions } from 'utils/authorization';
import { TABLE_COLUMN_MAX_WIDTH } from 'utils/consts';
import styles from './Incident.module.scss';
const cx = cn.bind(styles);
export function getIncidentStatusTag(alert: Alert) {
switch (alert.status) {
case IncidentStatus.Firing:
return (
<Tag color={getComputedStyle(document.documentElement).getPropertyValue('--tag-danger')}>
<Tag color="var(--tag-danger)" className={cx('status-tag')}>
<Text strong size="small">
Firing
</Text>
@ -28,7 +33,7 @@ export function getIncidentStatusTag(alert: Alert) {
);
case IncidentStatus.Acknowledged:
return (
<Tag color={getComputedStyle(document.documentElement).getPropertyValue('--tag-warning')}>
<Tag color="var(--tag-warning)" className={cx('status-tag')}>
<Text strong size="small">
Acknowledged
</Text>
@ -36,7 +41,7 @@ export function getIncidentStatusTag(alert: Alert) {
);
case IncidentStatus.Resolved:
return (
<Tag color={getComputedStyle(document.documentElement).getPropertyValue('--tag-primary')}>
<Tag color="var(--tag-primary)" className={cx('status-tag')}>
<Text strong size="small">
Resolved
</Text>
@ -44,7 +49,7 @@ export function getIncidentStatusTag(alert: Alert) {
);
case IncidentStatus.Silenced:
return (
<Tag color={getComputedStyle(document.documentElement).getPropertyValue('--tag-secondary')}>
<Tag color="var(--tag-secondary)" className={cx('status-tag')}>
<Text strong size="small">
Silenced
</Text>

View file

@ -122,6 +122,45 @@
margin-left: 4px;
}
.integration-logo {
margin-right: 8px;
}
.label-button {
padding: 0 8px;
font-weight: 400;
}
.label-button:disabled {
border: var(--border-strong);
}
.label-button-text {
max-width: 160px;
overflow: hidden;
position: relative;
display: inline-block;
text-align: center;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-name {
display: flex;
align-items: center;
}
.status-tag-container {
margin-right: 8px;
display: inherit;
}
.status-tag {
height: 24px;
padding: 0 8px;
border-radius: 2px;
}
.paged-users {
width: 100%;
}

View file

@ -58,11 +58,12 @@ import { UserActions } from 'utils/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import sanitize from 'utils/sanitize';
import { getActionButtons, getIncidentStatusTag, renderRelatedUsers } from './Incident.helpers';
import { getActionButtons, getIncidentStatusTag } from './Incident.helpers';
import styles from './Incident.module.scss';
import PagedUsers from './parts/PagedUsers';
const cx = cn.bind(styles);
const INTEGRATION_NAME_LENGTH_LIMIT = 30;
interface IncidentPageProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
@ -235,6 +236,9 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
const integration = store.alertReceiveChannelStore.getIntegration(incident.alert_receive_channel);
const showLinkTo = !incident.dependent_alert_groups.length && !incident.root_alert_group && !incident.resolved;
const integrationNameWithEmojies = <Emoji text={incident.alert_receive_channel.verbal_name} />;
return (
<Block withBackground className={cx('block')}>
<VerticalGroup>
@ -269,7 +273,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
<Text>
{showLinkTo && (
<IconButton
name="share-alt"
name="code-branch"
onClick={this.showAttachIncidentForm}
tooltip="Attach to another Alert Group"
className={cx('title-icon')}
@ -291,11 +295,61 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
</HorizontalGroup>
<div className={cx('info-row')}>
<HorizontalGroup>
{getIncidentStatusTag(incident)} | <Emoji text={incident.alert_receive_channel.verbal_name} />|
<IntegrationLogo integration={integration} scale={0.1} />
{integration && <Text type="secondary"> {integration?.display_name}</Text>}
{integration && '|'}
<Text type="secondary">{renderRelatedUsers(incident, true)}</Text>
<div className={cx('status-tag-container')}>{getIncidentStatusTag(incident)}</div>
<PluginLink
disabled={incident.alert_receive_channel.deleted}
query={{ page: 'integrations', id: incident.alert_receive_channel.id }}
>
<Button
disabled={incident.alert_receive_channel.deleted}
variant="secondary"
fill="outline"
size="sm"
className={cx('label-button')}
icon="plug"
>
<Tooltip
placement="top"
content={
incident.alert_receive_channel.verbal_name.length > INTEGRATION_NAME_LENGTH_LIMIT
? integrationNameWithEmojies
: 'Go to Integration'
}
>
<div className={cx('label-button-text')}>{integrationNameWithEmojies}</div>
</Tooltip>
</Button>
</PluginLink>
{integration && (
<>
<Tooltip
placement="top"
content={
incident.render_for_web.source_link === null
? `The integration doesn't have direct link to the source.`
: 'Go to source'
}
>
<a href={incident.render_for_web.source_link} target="_blank" rel="noreferrer">
<Button
variant="secondary"
fill="outline"
size="sm"
disabled={incident.render_for_web.source_link === null}
className={cx('label-button')}
>
<div className={cx('label-button-text', 'source-name')}>
<div className={cx('integration-logo')}>
<IntegrationLogo integration={integration} scale={0.08} />
</div>
{integration.display_name}
</div>
</Button>
</a>
</Tooltip>
</>
)}
</HorizontalGroup>
</div>
<HorizontalGroup justify="space-between" className={cx('buttons-row')}>
@ -310,7 +364,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
})}
<PluginBridge plugin={SupportedPlugin.Incident}>
<a href={incident.declare_incident_link} target="_blank" rel="noreferrer">
<Button variant="primary" size="sm" icon="fire">
<Button variant="secondary" size="md" icon="fire">
Declare incident
</Button>
</a>
@ -324,14 +378,7 @@ class IncidentPage extends React.Component<IncidentPageProps, IncidentPageState>
value={prepareForEdit(incident.paged_users)}
onUpdateEscalationVariants={this.handleAddResponders}
/>
<PluginLink
disabled={incident.alert_receive_channel.deleted}
query={{ page: 'integrations', id: incident.alert_receive_channel.id }}
>
<Button disabled={incident.alert_receive_channel.deleted} variant="secondary" icon="compass">
Go to Integration
</Button>
</PluginLink>
<Button
disabled={incident.alert_receive_channel.deleted}
variant="secondary"

View file

@ -40,6 +40,8 @@ import 'interceptors';
import { rootStore } from 'state';
import { useStore } from 'state/useStore';
import { isUserActionAllowed } from 'utils/authorization';
import { reCAPTCHA_site_key } from 'utils/consts';
import loadJs from 'utils/loadJs';
dayjs.extend(utc);
dayjs.extend(timezone);
@ -89,6 +91,10 @@ export const Root = observer((props: AppRootProps) => {
};
}, []);
useEffect(() => {
loadJs(`https://www.google.com/recaptcha/api.js?render=${reCAPTCHA_site_key}`);
}, []);
const updateBasicData = async () => {
await store.updateBasicData();
setDidFinishLoading(true);

View file

@ -48,3 +48,7 @@
padding-left: 4px;
padding-right: 4px;
}
.grecaptcha-badge {
visibility: hidden;
}

View file

@ -15,6 +15,9 @@ export const DEFAULT_PAGE = 'incidents';
export const PLUGIN_ROOT = '/a/grafana-oncall-app';
// https://developers.google.com/recaptcha/docs/v3
export const reCAPTCHA_site_key = '6LeIPJ8kAAAAAJdUfjO3uUtQtVxsYf93y46mTec1';
// Environment options list for onCallApiUrl
export const ONCALL_PROD = 'https://oncall-prod-us-central-0.grafana.net/oncall';
export const ONCALL_OPS = 'https://oncall-ops-us-east-0.grafana.net/oncall';

View file

@ -0,0 +1,6 @@
export default function loadJs(url: string) {
let script = document.createElement('script');
script.src = url;
document.head.appendChild(script);
}