Skip to content

Commit 4680512

Browse files
authored
Merge pull request #5180 from grafana/dev
v1.11.1
2 parents fc60847 + 4667960 commit 4680512

33 files changed

+217
-68
lines changed

docs/sources/manage/notify/slack/index.md

-6
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,6 @@ This set of permissions is supporting the ability of Grafana OnCall to match use
108108
- **View user groups in your workspace**
109109
- **View profile details about people in your workspace**
110110

111-
### Perform actions as you
112-
113-
- **Send messages on your behalf** — this permission may sound suspicious, but it's actually a general ability
114-
to send messages as the bot: <https://api.slack.com/scopes/chat:write> Grafana OnCall will not impersonate or post
115-
using your handle to slack. It will always post as the bot.
116-
117111
### Perform actions in channels & conversations
118112

119113
- **View messages that directly mention @grafana_oncall in conversations that the app is in**

docs/sources/set-up/open-source/index.md

-1
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,6 @@ oauth_config:
122122
scopes:
123123
user:
124124
- channels:read
125-
- chat:write
126125
- identify
127126
- users.profile:read
128127
bot:

engine/apps/alerts/incident_appearance/renderers/slack_renderer.py

-6
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,6 @@ def _make_button(text, action_id_step_class_name, action_id_scenario_step="distr
202202
unsilence_button = _make_button("Unsilence", "UnSilenceGroupStep")
203203
responders_button = _make_button("Responders", "StartManageResponders", "manage_responders")
204204
attach_button = _make_button("Attach to ...", "SelectAttachGroupStep")
205-
format_alert_button = _make_button(
206-
":mag: Format Alert", "OpenAlertAppearanceDialogStep", "alertgroup_appearance"
207-
)
208205

209206
resolution_notes_count = alert_group.resolution_notes.count()
210207
resolution_notes_button = {
@@ -275,9 +272,6 @@ def _make_button(text, action_id_step_class_name, action_id_scenario_step="distr
275272
else:
276273
buttons.append(unresolve_button)
277274

278-
if integration.is_available_for_custom_templates:
279-
buttons.append(format_alert_button)
280-
281275
buttons.append(resolution_notes_button)
282276

283277
if grafana_incident_enabled and not alert_group.acknowledged:

engine/apps/alerts/tasks/notify_user.py

+28-7
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
from apps.user_management.models import User
2727

2828

29+
RETRY_TIMEOUT_HOURS = 1
30+
31+
2932
def schedule_send_bundled_notification_task(
3033
user_notification_bundle: "UserNotificationBundle", alert_group: "AlertGroup"
3134
):
@@ -445,10 +448,29 @@ def perform_notification(log_record_pk, use_default_notification_policy_fallback
445448
try:
446449
TelegramToUserConnector.notify_user(user, alert_group, notification_policy)
447450
except RetryAfter as e:
448-
countdown = getattr(e, "retry_after", 3)
449-
raise perform_notification.retry(
450-
(log_record_pk, use_default_notification_policy_fallback), countdown=countdown, exc=e
451-
)
451+
task_logger.exception(f"Telegram API rate limit exceeded. Retry after {e.retry_after} seconds.")
452+
# check how much time has passed since log record was created
453+
# to prevent eternal loop of restarting perform_notification task
454+
if timezone.now() < log_record.created_at + timezone.timedelta(hours=RETRY_TIMEOUT_HOURS):
455+
countdown = getattr(e, "retry_after", 3)
456+
perform_notification.apply_async(
457+
(log_record_pk, use_default_notification_policy_fallback), countdown=countdown
458+
)
459+
else:
460+
task_logger.debug(
461+
f"telegram notification for alert_group {alert_group.pk} failed because of rate limit"
462+
)
463+
UserNotificationPolicyLogRecord(
464+
author=user,
465+
type=UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED,
466+
notification_policy=notification_policy,
467+
reason="Telegram rate limit exceeded",
468+
alert_group=alert_group,
469+
notification_step=notification_policy.step,
470+
notification_channel=notification_channel,
471+
notification_error_code=UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_TELEGRAM_RATELIMIT,
472+
).save()
473+
return
452474

453475
elif notification_channel == UserNotificationPolicy.NotificationChannel.SLACK:
454476
# TODO: refactor checking the possibility of sending a notification in slack
@@ -516,13 +538,12 @@ def perform_notification(log_record_pk, use_default_notification_policy_fallback
516538
).save()
517539
return
518540

519-
retry_timeout_hours = 1
520541
if alert_group.slack_message:
521542
alert_group.slack_message.send_slack_notification(user, alert_group, notification_policy)
522543
task_logger.debug(f"Finished send_slack_notification for alert_group {alert_group.pk}.")
523544
# check how much time has passed since log record was created
524545
# to prevent eternal loop of restarting perform_notification task
525-
elif timezone.now() < log_record.created_at + timezone.timedelta(hours=retry_timeout_hours):
546+
elif timezone.now() < log_record.created_at + timezone.timedelta(hours=RETRY_TIMEOUT_HOURS):
526547
task_logger.debug(
527548
f"send_slack_notification for alert_group {alert_group.pk} failed because slack message "
528549
f"does not exist. Restarting perform_notification."
@@ -534,7 +555,7 @@ def perform_notification(log_record_pk, use_default_notification_policy_fallback
534555
else:
535556
task_logger.debug(
536557
f"send_slack_notification for alert_group {alert_group.pk} failed because slack message "
537-
f"after {retry_timeout_hours} hours still does not exist"
558+
f"after {RETRY_TIMEOUT_HOURS} hours still does not exist"
538559
)
539560
UserNotificationPolicyLogRecord(
540561
author=user,

engine/apps/alerts/tests/test_notify_user.py

+19-1
Original file line numberDiff line numberDiff line change
@@ -360,12 +360,30 @@ def test_perform_notification_telegram_retryafter_error(
360360
countdown = 15
361361
exc = RetryAfter(countdown)
362362
with patch.object(TelegramToUserConnector, "notify_user", side_effect=exc) as mock_notify_user:
363-
with pytest.raises(RetryAfter):
363+
with patch.object(perform_notification, "apply_async") as mock_apply_async:
364364
perform_notification(log_record.pk, False)
365365

366366
mock_notify_user.assert_called_once_with(user, alert_group, user_notification_policy)
367+
# task is rescheduled using the countdown value from the exception
368+
mock_apply_async.assert_called_once_with((log_record.pk, False), countdown=countdown)
367369
assert alert_group.personal_log_records.last() == log_record
368370

371+
# but if the log was too old, skip and create a failed log record
372+
log_record.created_at = timezone.now() - timezone.timedelta(minutes=90)
373+
log_record.save()
374+
with patch.object(TelegramToUserConnector, "notify_user", side_effect=exc) as mock_notify_user:
375+
with patch.object(perform_notification, "apply_async") as mock_apply_async:
376+
perform_notification(log_record.pk, False)
377+
mock_notify_user.assert_called_once_with(user, alert_group, user_notification_policy)
378+
assert not mock_apply_async.called
379+
last_log_record = UserNotificationPolicyLogRecord.objects.last()
380+
assert last_log_record.type == UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED
381+
assert last_log_record.reason == "Telegram rate limit exceeded"
382+
assert (
383+
last_log_record.notification_error_code
384+
== UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_TELEGRAM_RATELIMIT
385+
)
386+
369387

370388
@patch("apps.base.models.UserNotificationPolicy.get_default_fallback_policy")
371389
@patch("apps.base.tests.messaging_backend.TestOnlyBackend.notify_user")

engine/apps/base/models/user_notification_policy_log_record.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ class UserNotificationPolicyLogRecord(models.Model):
106106
ERROR_NOTIFICATION_TELEGRAM_USER_IS_DEACTIVATED,
107107
ERROR_NOTIFICATION_MOBILE_USER_HAS_NO_ACTIVE_DEVICE,
108108
ERROR_NOTIFICATION_FORMATTING_ERROR,
109-
) = range(29)
109+
ERROR_NOTIFICATION_IN_TELEGRAM_RATELIMIT,
110+
) = range(30)
110111

111112
# for this errors we want to send message to general log channel
112113
ERRORS_TO_SEND_IN_SLACK_CHANNEL = [
@@ -304,6 +305,10 @@ def render_log_line_action(self, for_slack=False, substitute_author_with_tag=Fal
304305
result += f"failed to notify {user_verbal} in Slack, because channel is archived"
305306
elif self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_SLACK_RATELIMIT:
306307
result += f"failed to notify {user_verbal} in Slack due to Slack rate limit"
308+
elif (
309+
self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_IN_TELEGRAM_RATELIMIT
310+
):
311+
result += f"failed to notify {user_verbal} in Telegram due to Telegram rate limit"
307312
elif self.notification_error_code == UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_FORBIDDEN:
308313
result += f"failed to notify {user_verbal}, not allowed"
309314
elif (

engine/apps/grafana_plugin/helpers/client.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
from apps.api.permissions import GrafanaAPIPermission, GrafanaAPIPermissions
1212
from common.constants.plugin_ids import PluginID
1313

14+
if typing.TYPE_CHECKING:
15+
from apps.user_management.models import Organization
16+
1417
logger = logging.getLogger(__name__)
1518

1619

@@ -309,6 +312,9 @@ def get_grafana_incident_plugin_settings(self) -> APIClientResponse["GrafanaAPIC
309312
def get_grafana_labels_plugin_settings(self) -> APIClientResponse["GrafanaAPIClient.Types.PluginSettings"]:
310313
return self.get_grafana_plugin_settings(PluginID.LABELS)
311314

315+
def get_grafana_irm_plugin_settings(self) -> APIClientResponse["GrafanaAPIClient.Types.PluginSettings"]:
316+
return self.get_grafana_plugin_settings(PluginID.IRM)
317+
312318
def get_service_account(self, login: str) -> APIClientResponse["GrafanaAPIClient.Types.ServiceAccountResponse"]:
313319
return self.api_get(f"api/serviceaccounts/search?query={login}")
314320

@@ -328,8 +334,8 @@ def create_service_account_token(
328334
def get_service_account_token_permissions(self) -> APIClientResponse[typing.Dict[str, typing.List[str]]]:
329335
return self.api_get("api/access-control/user/permissions")
330336

331-
def sync(self) -> APIClientResponse:
332-
return self.api_post("api/plugins/grafana-oncall-app/resources/plugin/sync")
337+
def sync(self, organization: "Organization") -> APIClientResponse:
338+
return self.api_post(f"api/plugins/{organization.active_ui_plugin_id}/resources/plugin/sync")
333339

334340
@staticmethod
335341
def validate_grafana_token_format(grafana_token: str) -> bool:

engine/apps/grafana_plugin/serializers/sync_data.py

+1
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class SyncOnCallSettingsSerializer(serializers.Serializer):
7171
incident_enabled = serializers.BooleanField()
7272
incident_backend_url = serializers.CharField(allow_blank=True)
7373
labels_enabled = serializers.BooleanField()
74+
irm_enabled = serializers.BooleanField(default=False)
7475

7576
def create(self, validated_data):
7677
return SyncSettings(**validated_data)

engine/apps/grafana_plugin/sync_data.py

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class SyncSettings:
4040
incident_enabled: bool
4141
incident_backend_url: str
4242
labels_enabled: bool
43+
irm_enabled: bool
4344

4445

4546
@dataclass

engine/apps/grafana_plugin/tasks/sync_v2.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ def sync_organizations_v2(org_ids=None):
4949
organization_qs = Organization.objects.filter(id__in=org_ids)
5050
for org in organization_qs:
5151
client = GrafanaAPIClient(api_url=org.grafana_url, api_token=org.api_token)
52-
_, status = client.sync()
52+
_, status = client.sync(org)
5353
if status["status_code"] != 200:
5454
logger.error(
5555
f"Failed to request sync org_id={org.pk} stack_slug={org.stack_slug} status_code={status['status_code']} url={status['url']} message={status['message']}"

engine/apps/grafana_plugin/tests/test_sync_v2.py

+74-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
from apps.api.permissions import LegacyAccessControlRole
1313
from apps.grafana_plugin.serializers.sync_data import SyncTeamSerializer
1414
from apps.grafana_plugin.sync_data import SyncData, SyncSettings, SyncUser
15-
from apps.grafana_plugin.tasks.sync_v2 import start_sync_organizations_v2
15+
from apps.grafana_plugin.tasks.sync_v2 import start_sync_organizations_v2, sync_organizations_v2
16+
from common.constants.plugin_ids import PluginID
1617

1718

1819
@pytest.mark.django_db
@@ -121,6 +122,7 @@ def test_sync_v2_content_encoding(
121122
incident_enabled=False,
122123
incident_backend_url="",
123124
labels_enabled=False,
125+
irm_enabled=False,
124126
),
125127
)
126128

@@ -140,6 +142,57 @@ def test_sync_v2_content_encoding(
140142
mock_sync.assert_called()
141143

142144

145+
@pytest.mark.parametrize(
146+
"irm_enabled,expected",
147+
[
148+
(True, True),
149+
(False, False),
150+
],
151+
)
152+
@pytest.mark.django_db
153+
def test_sync_v2_irm_enabled(
154+
make_organization_and_user_with_plugin_token,
155+
make_user_auth_headers,
156+
settings,
157+
irm_enabled,
158+
expected,
159+
):
160+
settings.LICENSE = settings.CLOUD_LICENSE_NAME
161+
organization, _, token = make_organization_and_user_with_plugin_token()
162+
163+
assert organization.is_grafana_irm_enabled is False
164+
165+
client = APIClient()
166+
headers = make_user_auth_headers(None, token, organization=organization)
167+
url = reverse("grafana-plugin:sync-v2")
168+
169+
data = SyncData(
170+
users=[],
171+
teams=[],
172+
team_members={},
173+
settings=SyncSettings(
174+
stack_id=organization.stack_id,
175+
org_id=organization.org_id,
176+
license=settings.CLOUD_LICENSE_NAME,
177+
oncall_api_url="http://localhost",
178+
oncall_token="",
179+
grafana_url="http://localhost",
180+
grafana_token="fake_token",
181+
rbac_enabled=False,
182+
incident_enabled=False,
183+
incident_backend_url="",
184+
labels_enabled=False,
185+
irm_enabled=irm_enabled,
186+
),
187+
)
188+
189+
response = client.post(url, format="json", data=asdict(data), **headers)
190+
assert response.status_code == status.HTTP_200_OK
191+
192+
organization.refresh_from_db()
193+
assert organization.is_grafana_irm_enabled == expected
194+
195+
143196
@pytest.mark.parametrize(
144197
"test_team, validation_pass",
145198
[
@@ -190,3 +243,23 @@ def check_call(actual, expected):
190243
assert check_call(actual_call, expected_call)
191244

192245
assert mock_sync.call_count == len(expected_calls)
246+
247+
248+
@patch(
249+
"apps.grafana_plugin.tasks.sync_v2.GrafanaAPIClient.api_post",
250+
return_value=(None, {"status_code": status.HTTP_200_OK}),
251+
)
252+
@pytest.mark.parametrize(
253+
"is_grafana_irm_enabled,expected",
254+
[
255+
(True, PluginID.IRM),
256+
(False, PluginID.ONCALL),
257+
],
258+
)
259+
@pytest.mark.django_db
260+
def test_sync_organizations_v2_calls_right_backend_plugin_sync_endpoint(
261+
mocked_grafana_api_client_api_post, make_organization, is_grafana_irm_enabled, expected
262+
):
263+
org = make_organization(is_grafana_irm_enabled=is_grafana_irm_enabled)
264+
sync_organizations_v2(org_ids=[org.pk])
265+
mocked_grafana_api_client_api_post.assert_called_once_with(f"api/plugins/{expected}/resources/plugin/sync")

engine/apps/public_api/serializers/escalation_chains.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
from apps.alerts.models import EscalationChain
44
from common.api_helpers.custom_fields import TeamPrimaryKeyRelatedField
5+
from common.api_helpers.mixins import EagerLoadingMixin
56
from common.api_helpers.utils import CurrentOrganizationDefault
67

78

8-
class EscalationChainSerializer(serializers.ModelSerializer):
9+
class EscalationChainSerializer(EagerLoadingMixin, serializers.ModelSerializer):
910
id = serializers.ReadOnlyField(source="public_primary_key")
1011
organization = serializers.HiddenField(default=CurrentOrganizationDefault())
1112
team_id = TeamPrimaryKeyRelatedField(required=False, allow_null=True, source="team")
1213

14+
SELECT_RELATED = ["organization", "team"]
15+
1316
class Meta:
1417
model = EscalationChain
1518
fields = (

engine/apps/public_api/serializers/escalation_policies.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,13 @@ class Meta:
107107
]
108108

109109
PREFETCH_RELATED = ["notify_to_users_queue"]
110-
SELECT_RELATED = ["escalation_chain"]
110+
SELECT_RELATED = [
111+
"custom_webhook",
112+
"escalation_chain",
113+
"notify_schedule",
114+
"notify_to_group",
115+
"notify_to_team_members",
116+
]
111117

112118
@cached_property
113119
def escalation_chain(self):

engine/apps/public_api/serializers/integrations.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ class IntegrationSerializer(EagerLoadingMixin, serializers.ModelSerializer, Main
8585
description_short = serializers.CharField(max_length=250, required=False, allow_null=True)
8686

8787
PREFETCH_RELATED = ["channel_filters"]
88-
SELECT_RELATED = ["organization", "integration_heartbeat"]
88+
SELECT_RELATED = ["organization", "integration_heartbeat", "team"]
8989

9090
class Meta:
9191
model = AlertReceiveChannel

engine/apps/public_api/serializers/on_call_shifts.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ class Meta:
122122
"source": {"required": False, "write_only": True},
123123
}
124124

125-
SELECT_RELATED = ["schedule"]
125+
SELECT_RELATED = ["organization", "team", "schedule"]
126126
PREFETCH_RELATED = ["schedules", "users"]
127127

128128
def create(self, validated_data):

engine/apps/public_api/serializers/routes.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from apps.base.messaging import get_messaging_backend_from_id, get_messaging_backends
55
from common.api_helpers.custom_fields import OrganizationFilteredPrimaryKeyRelatedField
66
from common.api_helpers.exceptions import BadRequest
7+
from common.api_helpers.mixins import EagerLoadingMixin
78
from common.api_helpers.utils import valid_jinja_template_for_serializer_method_field
89
from common.jinja_templater.apply_jinja_template import JinjaTemplateError
910
from common.ordered_model.serializer import OrderedModelSerializer
@@ -129,7 +130,7 @@ def to_internal_value(self, data):
129130
raise BadRequest(detail="Invalid route type")
130131

131132

132-
class ChannelFilterSerializer(BaseChannelFilterSerializer):
133+
class ChannelFilterSerializer(EagerLoadingMixin, BaseChannelFilterSerializer):
133134
id = serializers.CharField(read_only=True, source="public_primary_key")
134135
slack = serializers.DictField(required=False)
135136
telegram = serializers.DictField(required=False)
@@ -146,6 +147,8 @@ class ChannelFilterSerializer(BaseChannelFilterSerializer):
146147

147148
is_the_last_route = serializers.BooleanField(read_only=True, source="is_default")
148149

150+
SELECT_RELATED = ["alert_receive_channel", "escalation_chain"]
151+
149152
class Meta:
150153
model = ChannelFilter
151154
fields = OrderedModelSerializer.Meta.fields + [

0 commit comments

Comments
 (0)