diff --git a/.drone.yml b/.drone.yml
index 2fd504cb..6a656d5c 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -236,10 +236,15 @@ steps:
- name: build and push docker image
image: thegeeklab/drone-docker-buildx:24.1.0
+ # From the docs (https://drone-plugin-index.geekdocs.de/plugins/drone-docker-buildx/) regarding privileged=true:
+ #
+ # Be aware that the this plugin requires privileged capabilities, otherwise the integrated
+ # Docker daemon is not able to start.
+ privileged: true
settings:
repo: grafana/oncall
- tags: ${DRONE_TAG}
- platform: linux/arm64/v8,linux/amd64
+ tags: latest,${DRONE_TAG}
+ platforms: linux/arm64/v8,linux/amd64
dockerfile: engine/Dockerfile
target: prod
context: engine/
@@ -329,6 +334,6 @@ kind: secret
name: drone_token
---
kind: signature
-hmac: 08184a4c1313a5e6cbca2370d20a6a8c5d71726b4148b5c3ed1396a21bebaa37
+hmac: be10373849d65e1f90bce64c7468d5cf5bac546285226ff8931a8b953163a752
...
diff --git a/.github/workflows/linting-and-tests.yml b/.github/workflows/linting-and-tests.yml
index 866a6de2..7d1e298a 100644
--- a/.github/workflows/linting-and-tests.yml
+++ b/.github/workflows/linting-and-tests.yml
@@ -468,8 +468,12 @@ jobs:
# hit 172.17.0.1 which proxies the request onto the host where port 30001 is the node port that is mapped
# to the OnCall API
ONCALL_API_URL: http://172.17.0.1:30001
- GRAFANA_USERNAME: oncall
- GRAFANA_PASSWORD: oncall
+ GRAFANA_ADMIN_USERNAME: oncall
+ GRAFANA_ADMIN_PASSWORD: oncall
+ GRAFANA_EDITOR_USERNAME: editor
+ GRAFANA_EDITOR_PASSWORD: editor
+ GRAFANA_VIEWER_USERNAME: viewer
+ GRAFANA_VIEWER_PASSWORD: viewer
MAILSLURP_API_KEY: ${{ secrets.MAILSLURP_API_KEY }}
working-directory: ./grafana-plugin
run: yarn test:integration
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9951042e..0e58114a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,13 @@ 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.3.4 (2023-07-05)
+
+### Added
+
+- Add full avatar URL for on-call users in schedule internal API by @vadimkerr ([#2414](https://github.com/grafana/oncall/pull/2414))
+- Add phone call using the zvonok.com service by @sreway ([#2339](https://github.com/grafana/oncall/pull/2339))
+
## v1.3.3 (2023-06-29)
### Added
@@ -29,6 +36,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Change permissions used during setup to better represent actions being taken by @mderynck ([#2242](https://github.com/grafana/oncall/pull/2242))
- Display 100000+ in stats when there are more than 100000 alert groups in the result ([#1901](https://github.com/grafana/oncall/pull/1901))
- Change OnCall plugin to use service accounts and api tokens for communicating with backend, by @mderynck ([#2385](https://github.com/grafana/oncall/pull/2385))
+- RabbitMQ Docker image upgraded from 3.7.19 to 3.12.0 in `docker-compose-developer.yml` and
+ `docker-compose-mysql-rabbitmq.yml`. **Note**: if you use one of these config files for your deployment
+ you _may_ need to follow the RabbitMQ "upgrade steps" listed [here](https://rabbitmq.com/upgrade.html#rabbitmq-version-upgradability)
+ by @joeyorlando ([#2359](https://github.com/grafana/oncall/pull/2359))
### Fixed
diff --git a/dev/README.md b/dev/README.md
index 212bb727..e8ae4fd2 100644
--- a/dev/README.md
+++ b/dev/README.md
@@ -192,7 +192,7 @@ To run these tests locally simply do the following:
```bash
npx playwright install # install playwright dependencies
-cp ./grafana-plugin/.env.example ./grafana-plugin/.env
+cp ./grafana-plugin/integration-tests/.env.example ./grafana-plugin/integration-tests/.env
# you may need to tweak the values in ./grafana-plugin/.env according to your local setup
cd grafana-plugin
yarn test:integration
diff --git a/docs/sources/open-source/_index.md b/docs/sources/open-source/_index.md
index 2abce683..10fcc2c3 100644
--- a/docs/sources/open-source/_index.md
+++ b/docs/sources/open-source/_index.md
@@ -207,7 +207,9 @@ The benefits of connecting to Grafana Cloud include:
To connect to Grafana Cloud, refer to the **Cloud** page in your OSS Grafana OnCall instance.
-## Twilio Setup
+## Supported Phone Providers
+
+### Twilio
Grafana OnCall supports Twilio SMS and phone call notifications delivery. If you prefer to configure SMS and phone call
notifications using Twilio, complete the following steps:
@@ -215,6 +217,30 @@ notifications using Twilio, complete the following steps:
1. Set `GRAFANA_CLOUD_NOTIFICATIONS_ENABLED` as **False** to ensure the Grafana OSS <-> Cloud connector is disabled.
1. From your **OnCall** environment, select **Env Variables** and configure all variables starting with `TWILIO_`.
+### Zvonok.com
+
+Grafana OnCall supports Zvonok.com phone call notifications delivery. To configure phone call notifications using
+Zvonok.com, complete the following steps:
+
+1. Change `PHONE_PROVIDER` value to `zvonok`.
+2. Create a public API key on the Profile->Settings page, and assign its value to `ZVONOK_API_KEY`.
+3. Create campaign and assign its ID value to `ZVONOK_CAMPAIGN_ID`.
+4. If you are planning to use pre-recorded audio instead of a speech synthesizer, you can copy the ID of the audio clip
+to the variable `ZVONOK_AUDIO_ID` (optional step).
+5. To make a call with a specific voice, you can set the `ZVONOK_SPEAKER_ID`.
+By default, the ID used is `Salli` (optional step).
+6. To process the call status, it is required to add a postback with the GET/POST method on the side of the zvonok.com
+service with the following format (optional step):
+`${ONCALL_BASE_URL}/zvonok/call_status_events?campaign_id={ct_campaign_id}&call_id={ct_call_id}&status={ct_status}&user_choice={ct_user_choice}`
+
+The names of the transmitted parameters can be redefined through environment variables:
+
+- `ZVONOK_POSTBACK_CALL_ID` - call id (ct_call_id) query parameter name
+- `ZVONOK_POSTBACK_CAMPAIGN_ID` - company id (ct_campaign_id) query parameter name
+- `ZVONOK_POSTBACK_STATUS` - status (ct_status) query parameter name
+- `ZVONOK_POSTBACK_USER_CHOICE` - user choice (ct_user_choice) query parameter name
+- `ZVONOK_POSTBACK_USER_CHOICE_ACK` - user choice (ct_user_choice) query parameter value for acknowledge alert group
+
## Email Setup
Grafana OnCall is capable of sending emails using SMTP as a user notification step. To setup email notifications, populate
diff --git a/engine/apps/api/tests/test_schedules.py b/engine/apps/api/tests/test_schedules.py
index cf08592f..69ae02dc 100644
--- a/engine/apps/api/tests/test_schedules.py
+++ b/engine/apps/api/tests/test_schedules.py
@@ -1858,3 +1858,36 @@ def test_get_schedule_from_other_team_with_flag(
response = client.get(url, format="json", **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
+
+
+@pytest.mark.django_db
+def test_get_schedule_on_call_now(
+ make_organization, make_user_for_organization, make_token_for_organization, make_schedule, make_user_auth_headers
+):
+ organization = make_organization(grafana_url="https://example.com")
+ user = make_user_for_organization(organization, username="test", avatar_url="/avatar/test123")
+ _, token = make_token_for_organization(organization)
+
+ schedule = make_schedule(
+ organization,
+ schedule_class=OnCallScheduleWeb,
+ name="test_web_schedule",
+ )
+
+ client = APIClient()
+ url = reverse("api-internal:schedule-list")
+ with patch(
+ "apps.schedules.models.on_call_schedule.OnCallScheduleQuerySet.get_oncall_users",
+ return_value={schedule.pk: [user]},
+ ):
+ response = client.get(url, format="json", **make_user_auth_headers(user, token))
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["results"][0]["on_call_now"] == [
+ {
+ "pk": user.public_primary_key,
+ "username": "test",
+ "avatar": "/avatar/test123",
+ "avatar_full": "https://example.com/avatar/test123",
+ }
+ ]
diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py
index 1c619d25..b0e41897 100644
--- a/engine/apps/api/views/features.py
+++ b/engine/apps/api/views/features.py
@@ -14,7 +14,6 @@ FEATURE_GRAFANA_CLOUD_NOTIFICATIONS = "grafana_cloud_notifications"
FEATURE_GRAFANA_CLOUD_CONNECTION = "grafana_cloud_connection"
FEATURE_WEB_SCHEDULES = "web_schedules"
FEATURE_WEBHOOKS2 = "webhooks2"
-FEATURE_MOBILE_TEST_PUSH = "mobile_test_push"
class FeaturesAPIView(APIView):
@@ -64,12 +63,4 @@ class FeaturesAPIView(APIView):
if is_webhooks_enabled_for_organization(request.auth.organization.pk):
enabled_features.append(FEATURE_WEBHOOKS2)
- enabled_mobile_test_push = DynamicSetting.objects.get_or_create(
- name="enabled_mobile_test_push",
- defaults={"boolean_value": False},
- )[0]
-
- if enabled_mobile_test_push.boolean_value:
- enabled_features.append(FEATURE_MOBILE_TEST_PUSH)
-
return enabled_features
diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py
index 34600ddb..f5a6f8e7 100644
--- a/engine/apps/base/models/live_setting.py
+++ b/engine/apps/base/models/live_setting.py
@@ -60,6 +60,15 @@ class LiveSetting(models.Model):
"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED",
"DANGEROUS_WEBHOOKS_ENABLED",
"PHONE_PROVIDER",
+ "ZVONOK_API_KEY",
+ "ZVONOK_CAMPAIGN_ID",
+ "ZVONOK_AUDIO_ID",
+ "ZVONOK_SPEAKER_ID",
+ "ZVONOK_POSTBACK_CALL_ID",
+ "ZVONOK_POSTBACK_CAMPAIGN_ID",
+ "ZVONOK_POSTBACK_STATUS",
+ "ZVONOK_POSTBACK_USER_CHOICE",
+ "ZVONOK_POSTBACK_USER_CHOICE_ACK",
)
DESCRIPTIONS = {
@@ -148,6 +157,15 @@ class LiveSetting(models.Model):
"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED": "Enable SMS/call notifications via Grafana Cloud OnCall",
"DANGEROUS_WEBHOOKS_ENABLED": "Enable outgoing webhooks to private networks",
"PHONE_PROVIDER": f"Phone provider name. Available options: {','.join(list(settings.PHONE_PROVIDERS.keys()))}",
+ "ZVONOK_API_KEY": "API public key. You can get it in Profile->Settings section.",
+ "ZVONOK_CAMPAIGN_ID": "Calls by API campaign ID. You can get it after campaign creation.",
+ "ZVONOK_AUDIO_ID": "Calls with specific audio. You can get it in Audioclips section.",
+ "ZVONOK_SPEAKER_ID": "Calls with speaker.",
+ "ZVONOK_POSTBACK_CALL_ID": "'Postback' call id (ct_call_id) query parameter name to validate a postback request.",
+ "ZVONOK_POSTBACK_CAMPAIGN_ID": "'Postback' company id (ct_campaign_id) query parameter name to validate a postback request.",
+ "ZVONOK_POSTBACK_STATUS": "'Postback' status (ct_status) query parameter name to validate a postback request.",
+ "ZVONOK_POSTBACK_USER_CHOICE": "'Postback' user choice (ct_user_choice) query parameter name (optional).",
+ "ZVONOK_POSTBACK_USER_CHOICE_ACK": "'Postback' user choice (ct_user_choice) query parameter value for acknowledge alert group (optional).",
}
SECRET_SETTING_NAMES = (
@@ -163,6 +181,7 @@ class LiveSetting(models.Model):
"SLACK_SIGNING_SECRET",
"TELEGRAM_TOKEN",
"GRAFANA_CLOUD_ONCALL_TOKEN",
+ "ZVONOK_API_KEY",
)
def __str__(self):
diff --git a/engine/apps/mobile_app/auth.py b/engine/apps/mobile_app/auth.py
index 72d0646a..5b1d2497 100644
--- a/engine/apps/mobile_app/auth.py
+++ b/engine/apps/mobile_app/auth.py
@@ -4,6 +4,7 @@ from rest_framework import exceptions
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from apps.auth_token.exceptions import InvalidToken
+from apps.user_management.exceptions import OrganizationDeletedException, OrganizationMovedException
from apps.user_management.models import User
from .models import MobileAppAuthToken, MobileAppVerificationToken
@@ -42,4 +43,9 @@ class MobileAppAuthTokenAuthentication(BaseAuthentication):
except InvalidToken:
return None, None
+ if auth_token.organization.is_moved:
+ raise OrganizationMovedException(auth_token.organization)
+ if auth_token.organization.deleted_at:
+ raise OrganizationDeletedException(auth_token.organization)
+
return auth_token.user, auth_token
diff --git a/engine/apps/mobile_app/demo_push.py b/engine/apps/mobile_app/demo_push.py
index 96ace3f9..1b6bcce2 100644
--- a/engine/apps/mobile_app/demo_push.py
+++ b/engine/apps/mobile_app/demo_push.py
@@ -9,8 +9,6 @@ from apps.mobile_app.exceptions import DeviceNotSet
from apps.mobile_app.tasks import FCMMessageData, MessageType, _construct_fcm_message, _send_push_notification, logger
from apps.user_management.models import User
-TEST_PUSH_TITLE = "Hi, this is a test notification from Grafana OnCall"
-
def send_test_push(user, critical=False):
device_to_notify = FCMDevice.objects.filter(user=user).first()
@@ -42,7 +40,7 @@ def _get_test_escalation_fcm_message(user: User, device_to_notify: FCMDevice, cr
) + MobileAppUserSettings.IOS_SOUND_NAME_EXTENSION # iOS app expects the filename to have an extension
fcm_message_data: FCMMessageData = {
- "title": TEST_PUSH_TITLE,
+ "title": get_test_push_title(critical),
# Pass user settings, so the Android app can use them to play the correct sound and volume
"default_notification_sound_name": (
mobile_app_user_settings.default_notification_sound_name
@@ -68,7 +66,7 @@ def _get_test_escalation_fcm_message(user: User, device_to_notify: FCMDevice, cr
apns_payload = APNSPayload(
aps=Aps(
thread_id=thread_id,
- alert=ApsAlert(title=TEST_PUSH_TITLE),
+ alert=ApsAlert(title=get_test_push_title(critical)),
sound=CriticalSound(
# The notification shouldn't be critical if the user has disabled "override DND" setting
critical=overrideDND,
@@ -84,3 +82,7 @@ def _get_test_escalation_fcm_message(user: User, device_to_notify: FCMDevice, cr
message_type = MessageType.CRITICAL if critical else MessageType.NORMAL
return _construct_fcm_message(message_type, device_to_notify, thread_id, fcm_message_data, apns_payload)
+
+
+def get_test_push_title(critical: bool) -> str:
+ return f"Hi, this is a {'critical ' if critical else ''}test notification from Grafana OnCall"
diff --git a/engine/apps/mobile_app/tests/test_demo_push.py b/engine/apps/mobile_app/tests/test_demo_push.py
index 35843529..cb6ae51a 100644
--- a/engine/apps/mobile_app/tests/test_demo_push.py
+++ b/engine/apps/mobile_app/tests/test_demo_push.py
@@ -1,7 +1,7 @@
import pytest
from fcm_django.models import FCMDevice
-from apps.mobile_app.demo_push import TEST_PUSH_TITLE, _get_test_escalation_fcm_message
+from apps.mobile_app.demo_push import _get_test_escalation_fcm_message, get_test_push_title
from apps.mobile_app.models import MobileAppUserSettings
@@ -33,8 +33,8 @@ def test_test_escalation_fcm_message_user_settings(
# Check expected test push content
assert message.apns.payload.aps.badge is None
- assert message.apns.payload.aps.alert.title == TEST_PUSH_TITLE
- assert message.data["title"] == TEST_PUSH_TITLE
+ assert message.apns.payload.aps.alert.title == get_test_push_title(critical=False)
+ assert message.data["title"] == get_test_push_title(critical=False)
@pytest.mark.django_db
@@ -66,8 +66,8 @@ def test_escalation_fcm_message_user_settings_critical(
# Check expected test push content
assert message.apns.payload.aps.badge is None
- assert message.apns.payload.aps.alert.title == TEST_PUSH_TITLE
- assert message.data["title"] == TEST_PUSH_TITLE
+ assert message.apns.payload.aps.alert.title == get_test_push_title(critical=True)
+ assert message.data["title"] == get_test_push_title(critical=True)
@pytest.mark.django_db
@@ -91,5 +91,5 @@ def test_escalation_fcm_message_user_settings_critical_override_dnd_disabled(
# Check expected test push content
assert message.apns.payload.aps.badge is None
- assert message.apns.payload.aps.alert.title == TEST_PUSH_TITLE
- assert message.data["title"] == TEST_PUSH_TITLE
+ assert message.apns.payload.aps.alert.title == get_test_push_title(critical=True)
+ assert message.data["title"] == get_test_push_title(critical=True)
diff --git a/engine/apps/user_management/middlewares.py b/engine/apps/user_management/middlewares.py
index 528111e3..d9b65d08 100644
--- a/engine/apps/user_management/middlewares.py
+++ b/engine/apps/user_management/middlewares.py
@@ -46,6 +46,8 @@ class OrganizationMovedMiddleware(MiddlewareMixin):
return requests.delete(url, headers=headers)
elif method == "OPTIONS":
return requests.options(url, headers=headers)
+ elif method == "PATCH":
+ return requests.patch(url, data=body, headers=headers)
class OrganizationDeletedMiddleware(MiddlewareMixin):
diff --git a/engine/apps/user_management/models/user.py b/engine/apps/user_management/models/user.py
index 931f1bcb..5ca8c27f 100644
--- a/engine/apps/user_management/models/user.py
+++ b/engine/apps/user_management/models/user.py
@@ -270,7 +270,12 @@ class User(models.Model):
self._timezone = value
def short(self):
- return {"username": self.username, "pk": self.public_primary_key, "avatar": self.avatar_url}
+ return {
+ "username": self.username,
+ "pk": self.public_primary_key,
+ "avatar": self.avatar_url,
+ "avatar_full": self.avatar_full_url,
+ }
# Insight logs
@property
diff --git a/engine/apps/zvonok/__init__.py b/engine/apps/zvonok/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/engine/apps/zvonok/migrations/0001_initial.py b/engine/apps/zvonok/migrations/0001_initial.py
new file mode 100644
index 00000000..b6a00919
--- /dev/null
+++ b/engine/apps/zvonok/migrations/0001_initial.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.2.19 on 2023-07-01 12:28
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('phone_notifications', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ZvonokPhoneCall',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('status', models.PositiveSmallIntegerField(blank=True, choices=[(10, 'attempts_exc'), (20, 'compl_finished'), (30, 'compl_nofinished'), (40, 'deleted'), (50, 'duration_error'), (60, 'expires'), (70, 'novalid_button'), (80, 'no_provider'), (90, 'interrupted'), (100, 'in_process'), (110, 'pincode_nook'), (130, 'synth_error'), (140, 'user')], null=True)),
+ ('call_id', models.CharField(blank=True, max_length=50)),
+ ('campaign_id', models.CharField(max_length=50)),
+ ('phone_call_record', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='zvonok_zvonokphonecall_related', related_query_name='zvonok_zvonokphonecalls', to='phone_notifications.phonecallrecord')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/engine/apps/zvonok/migrations/__init__.py b/engine/apps/zvonok/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/engine/apps/zvonok/models/__init__.py b/engine/apps/zvonok/models/__init__.py
new file mode 100644
index 00000000..11f80b07
--- /dev/null
+++ b/engine/apps/zvonok/models/__init__.py
@@ -0,0 +1 @@
+from .phone_call import ZvonokCallStatuses, ZvonokPhoneCall # noqa: F401
diff --git a/engine/apps/zvonok/models/phone_call.py b/engine/apps/zvonok/models/phone_call.py
new file mode 100644
index 00000000..d47fa93b
--- /dev/null
+++ b/engine/apps/zvonok/models/phone_call.py
@@ -0,0 +1,71 @@
+from django.db import models
+
+from apps.phone_notifications.phone_provider import ProviderPhoneCall
+
+
+class ZvonokCallStatuses:
+
+ ATTEMPTS_EXC = 10
+ COMPL_FINISHED = 20
+ COMPL_NOFINISHED = 30
+ DELETED = 40
+ DURATION_ERROR = 50
+ EXPIRES = 60
+ NOVALID_BUTTON = 70
+ NO_PROVIDER = 80
+ INTERRUPTED = 90
+ IN_PROCESS = 100
+ PINCODE_NOOK = 110
+ PINCODE_OK = 120
+ SYNTH_ERROR = 130
+ USER = 140
+
+ CHOICES = (
+ (ATTEMPTS_EXC, "attempts_exc"),
+ (COMPL_FINISHED, "compl_finished"),
+ (COMPL_NOFINISHED, "compl_nofinished"),
+ (DELETED, "deleted"),
+ (DURATION_ERROR, "duration_error"),
+ (EXPIRES, "expires"),
+ (NOVALID_BUTTON, "novalid_button"),
+ (NO_PROVIDER, "no_provider"),
+ (INTERRUPTED, "interrupted"),
+ (IN_PROCESS, "in_process"),
+ (PINCODE_NOOK, "pincode_nook"),
+ (SYNTH_ERROR, "synth_error"),
+ (USER, "user"),
+ )
+
+ DETERMINANT = {
+ "attempts_exc": ATTEMPTS_EXC,
+ "compl_finished": COMPL_FINISHED,
+ "deleted": DELETED,
+ "duration_error": DURATION_ERROR,
+ "expires": EXPIRES,
+ "novalid_button": NOVALID_BUTTON,
+ "no_provider": NO_PROVIDER,
+ "interrupted": INTERRUPTED,
+ "in_process": IN_PROCESS,
+ "pincode_nook": PINCODE_NOOK,
+ "synth_error": SYNTH_ERROR,
+ "user": USER,
+ }
+
+
+class ZvonokPhoneCall(ProviderPhoneCall, models.Model):
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ status = models.PositiveSmallIntegerField(
+ blank=True,
+ null=True,
+ choices=ZvonokCallStatuses.CHOICES,
+ )
+
+ call_id = models.CharField(
+ blank=True,
+ max_length=50,
+ )
+
+ campaign_id = models.CharField(
+ max_length=50,
+ )
diff --git a/engine/apps/zvonok/phone_provider.py b/engine/apps/zvonok/phone_provider.py
new file mode 100644
index 00000000..f3f99952
--- /dev/null
+++ b/engine/apps/zvonok/phone_provider.py
@@ -0,0 +1,150 @@
+import logging
+from random import randint
+from typing import Optional
+
+import requests
+from django.core.cache import cache
+
+from apps.base.utils import live_settings
+from apps.phone_notifications.exceptions import FailedToMakeCall, FailedToStartVerification
+from apps.phone_notifications.phone_provider import PhoneProvider, ProviderFlags
+from apps.zvonok.models.phone_call import ZvonokCallStatuses, ZvonokPhoneCall
+
+ZVONOK_CALL_URL = "https://zvonok.com/manager/cabapi_external/api/v1/phones/call/"
+
+logger = logging.getLogger(__name__)
+
+
+class ZvonokPhoneProvider(PhoneProvider):
+ """
+ ZvonokPhoneProvider is an implementation of phone provider which supports only voice calls (zvonok.com).
+ """
+
+ def make_notification_call(self, number: str, message: str) -> ZvonokPhoneCall:
+ speaker = None
+ body = None
+
+ if live_settings.ZVONOK_AUDIO_ID:
+ message = f''
+ else:
+ speaker = live_settings.ZVONOK_SPEAKER_ID
+
+ try:
+ response = self._call_create(number, message, speaker)
+ response.raise_for_status()
+ body = response.json()
+ if not body:
+ logger.error(f"ZvonokPhoneProvider.make_notification_call: failed, empty body")
+ raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number}, empty body")
+ call_id = body.get("call_id")
+
+ if not call_id:
+ logger.error(f"ZvonokPhoneProvider.make_notification_call: failed, missing call id")
+ raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(body, number))
+
+ logger.info(f"ZvonokPhoneProvider.make_notification_call: success, call_id {call_id}")
+
+ return ZvonokPhoneCall(
+ status=ZvonokCallStatuses.IN_PROCESS,
+ call_id=call_id,
+ campaign_id=live_settings.ZVONOK_CAMPAIGN_ID,
+ )
+
+ except requests.exceptions.HTTPError as http_err:
+ logger.error(f"ZvonokPhoneProvider.make_notification_call: failed {http_err}")
+ raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(body, number))
+ except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err:
+ logger.error(f"ZvonokPhoneProvider.make_notification_call: failed {err}")
+ raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number}")
+
+ def make_call(self, number: str, message: str):
+ body = None
+ speaker = live_settings.ZVONOK_SPEAKER_ID
+
+ try:
+ response = self._call_create(number, message, speaker)
+ response.raise_for_status()
+ body = response.json()
+ if not body:
+ logger.error(f"ZvonokPhoneProvider.make_call: failed, empty body")
+ raise FailedToMakeCall(graceful_msg=f"Failed make call to {number}, empty body")
+
+ call_id = body.get("call_id")
+
+ if not call_id:
+ raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(body, number))
+ logger.info(f"ZvonokPhoneProvider.make_call: success, call_id {call_id}")
+
+ except requests.exceptions.HTTPError as http_err:
+ logger.error(f"ZvonokPhoneProvider.make_call: failed {http_err}")
+ raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(body, number))
+ except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err:
+ logger.error(f"ZvonokPhoneProvider.make_call: failed {err}")
+ raise FailedToMakeCall(graceful_msg=f"Failed make call to {number}")
+
+ def _call_create(self, number: str, text: str, speaker: Optional[str] = None):
+ params = {
+ "public_key": live_settings.ZVONOK_API_KEY,
+ "campaign_id": live_settings.ZVONOK_CAMPAIGN_ID,
+ "phone": number,
+ "text": text,
+ }
+
+ if speaker:
+ params["speaker"] = speaker
+
+ return requests.post(ZVONOK_CALL_URL, params=params)
+
+ def _get_graceful_msg(self, body, number):
+ if body:
+ status = body.get("status")
+ data = body.get("data")
+ if status == "error" and data:
+ return f"Failed make call to {number} with error: {data}"
+ return f"Failed make call to {number}"
+
+ def make_verification_call(self, number: str):
+ code = str(randint(100000, 999999))
+ cache.set(self._cache_key(number), code, timeout=10 * 60)
+ codewspaces = " ".join(code)
+
+ body = None
+ speaker = live_settings.ZVONOK_SPEAKER_ID
+
+ try:
+ response = self._call_create(number, f"Your verification code is {codewspaces}", speaker)
+ response.raise_for_status()
+ body = response.json()
+ if not body:
+ logger.error(f"ZvonokPhoneProvider.make_verification_call: failed, empty body")
+ raise FailedToMakeCall(graceful_msg=f"Failed make verification call to {number}, empty body")
+
+ call_id = body.get("call_id")
+ if not call_id:
+ raise FailedToStartVerification(graceful_msg=self._get_graceful_msg(body, number))
+ except requests.exceptions.HTTPError as http_err:
+ logger.error(f"ZvonokPhoneProvider.make_verification_call: failed {http_err}")
+ raise FailedToStartVerification(graceful_msg=self._get_graceful_msg(body, number))
+ except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err:
+ logger.error(f"ZvonokPhoneProvider.make_verification_call: failed {err}")
+ raise FailedToStartVerification(graceful_msg=f"Failed make verification call to {number}")
+
+ def finish_verification(self, number, code):
+ has = cache.get(self._cache_key(number))
+ if has is not None and has == code:
+ return number
+ else:
+ return None
+
+ def _cache_key(self, number):
+ return f"zvonok_provider_{number}"
+
+ @property
+ def flags(self) -> ProviderFlags:
+ return ProviderFlags(
+ configured=True,
+ test_sms=False,
+ test_call=True,
+ verification_call=True,
+ verification_sms=False,
+ )
diff --git a/engine/apps/zvonok/status_callback.py b/engine/apps/zvonok/status_callback.py
new file mode 100644
index 00000000..83abbf87
--- /dev/null
+++ b/engine/apps/zvonok/status_callback.py
@@ -0,0 +1,86 @@
+import logging
+from typing import Optional
+
+from django.apps import apps
+
+from apps.alerts.constants import ActionSource
+from apps.alerts.signals import user_notification_action_triggered_signal
+from apps.base.utils import live_settings
+from apps.zvonok.models.phone_call import ZvonokCallStatuses, ZvonokPhoneCall
+
+logger = logging.getLogger(__name__)
+
+
+def update_zvonok_call_status(call_id: str, call_status: str, user_choice: Optional[str] = None):
+ UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")
+
+ status_code = ZvonokCallStatuses.DETERMINANT.get(call_status)
+ if status_code is None:
+ logger.warning(f"zvonok.update_zvonok_call_status: unexpected status call_id={call_id} status={call_status}")
+ return
+
+ zvonok_phone_call = ZvonokPhoneCall.objects.filter(call_id=call_id).first()
+ if zvonok_phone_call is None:
+ logger.warning(f"zvonok.update_zvonok_call_status: zvonok_phone_call not found call_id={call_id}")
+ return
+
+ logger.info(f"zvonok.update_zvonok_call_status: found zvonok_phone_call call_id={call_id}")
+
+ zvonok_phone_call.status = status_code
+ zvonok_phone_call.save(update_fields=["status"])
+ phone_call_record = zvonok_phone_call.phone_call_record
+
+ if phone_call_record is None:
+ logger.warning(
+ f"zvonok.update_zvonok_call_status: zvonok_phone_call has no phone_call record call_id={call_id} "
+ f"status={call_status}"
+ )
+ return
+
+ logger.info(
+ f"zvonok.update_zvonok_call_status: found phone_call_record id={phone_call_record.id} "
+ f"call_id={call_id} status={call_status}"
+ )
+ log_record_type = None
+ log_record_error_code = None
+
+ success_statuses = [ZvonokCallStatuses.USER, ZvonokCallStatuses.COMPL_FINISHED]
+
+ if status_code in success_statuses:
+ log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS
+ else:
+ log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED
+ log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_FAILED
+
+ if log_record_type is not None:
+ log_record = UserNotificationPolicyLogRecord(
+ type=log_record_type,
+ notification_error_code=log_record_error_code,
+ author=phone_call_record.receiver,
+ notification_policy=phone_call_record.notification_policy,
+ alert_group=phone_call_record.represents_alert_group,
+ notification_step=phone_call_record.notification_policy.step
+ if phone_call_record.notification_policy
+ else None,
+ notification_channel=phone_call_record.notification_policy.notify_by
+ if phone_call_record.notification_policy
+ else None,
+ )
+ log_record.save()
+ logger.info(
+ f"zvonok.update_zvonok_call_status: created log_record log_record_id={log_record.id} "
+ f"type={log_record_type}"
+ )
+
+ user_notification_action_triggered_signal.send(sender=update_zvonok_call_status, log_record=log_record)
+
+ if user_choice and user_choice == live_settings.ZVONOK_POSTBACK_USER_CHOICE_ACK:
+ alert_group = phone_call_record.represents_alert_group
+ user = phone_call_record.receiver
+ logger.info(
+ f"zvonok.update_zvonok_call_status: processing user choice"
+ f" phone_call_record id={phone_call_record.id} zvonok_phone_call_id={call_id} "
+ f"alert_group_id={alert_group.id} user_id={user.id}"
+ )
+
+ alert_group.acknowledge_by_user(user, action_source=ActionSource.PHONE)
diff --git a/engine/apps/zvonok/urls.py b/engine/apps/zvonok/urls.py
new file mode 100644
index 00000000..0945d756
--- /dev/null
+++ b/engine/apps/zvonok/urls.py
@@ -0,0 +1,7 @@
+from django.urls import path
+
+from .views import CallStatusCallback
+
+urlpatterns = [
+ path("call_status_events", CallStatusCallback.as_view(), name="call_status_events"),
+]
diff --git a/engine/apps/zvonok/views.py b/engine/apps/zvonok/views.py
new file mode 100644
index 00000000..cc15cb91
--- /dev/null
+++ b/engine/apps/zvonok/views.py
@@ -0,0 +1,52 @@
+from django.apps import apps
+from rest_framework import status
+from rest_framework.permissions import BasePermission
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from apps.base.utils import live_settings
+
+from .status_callback import update_zvonok_call_status
+
+
+class AllowOnlyZvonok(BasePermission):
+ def has_permission(self, request, view):
+ call_id = request.GET.get(live_settings.ZVONOK_POSTBACK_CALL_ID)
+ if not call_id:
+ return False
+
+ campaign_id = request.GET.get(live_settings.ZVONOK_POSTBACK_CAMPAIGN_ID)
+ if not campaign_id:
+ return False
+
+ if campaign_id != live_settings.ZVONOK_CAMPAIGN_ID:
+ return False
+ ZvonokCall = apps.get_model("zvonok", "ZvonokPhoneCall")
+ call = ZvonokCall.objects.filter(call_id=call_id, campaign_id=campaign_id).first()
+ if call:
+ return self.validate_request(request)
+ return False
+
+ def validate_request(self, request):
+ if request.GET.get(live_settings.ZVONOK_POSTBACK_STATUS):
+ return True
+ return False
+
+
+# Receive Call Status from Zvonok
+class CallStatusCallback(APIView):
+ permission_classes = [AllowOnlyZvonok]
+
+ def get(self, request):
+ self._handle_call_status(request)
+ return Response(data="", status=status.HTTP_204_NO_CONTENT)
+
+ def post(self, request):
+ self._handle_call_status(request)
+ return Response(data="", status=status.HTTP_204_NO_CONTENT)
+
+ def _handle_call_status(self, request):
+ call_id = request.GET.get(live_settings.ZVONOK_POSTBACK_CALL_ID)
+ call_status = request.GET.get(live_settings.ZVONOK_POSTBACK_STATUS)
+ user_choice = request.GET.get(live_settings.ZVONOK_POSTBACK_USER_CHOICE)
+ update_zvonok_call_status(call_id=call_id, call_status=call_status, user_choice=user_choice)
diff --git a/engine/engine/urls.py b/engine/engine/urls.py
index 7f288adb..83ede866 100644
--- a/engine/engine/urls.py
+++ b/engine/engine/urls.py
@@ -65,6 +65,7 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
if settings.IS_OPEN_SOURCE:
urlpatterns += [
path("api/internal/v1/", include("apps.oss_installation.urls", namespace="oss_installation")),
+ path("zvonok/", include("apps.zvonok.urls")),
]
if settings.DEBUG:
diff --git a/engine/settings/base.py b/engine/settings/base.py
index 30a62bf2..b4bbe6e5 100644
--- a/engine/settings/base.py
+++ b/engine/settings/base.py
@@ -682,7 +682,7 @@ INSTALLED_ONCALL_INTEGRATIONS = [
]
if IS_OPEN_SOURCE:
- INSTALLED_APPS += ["apps.oss_installation"] # noqa
+ INSTALLED_APPS += ["apps.oss_installation", "apps.zvonok"] # noqa
CELERY_BEAT_SCHEDULE["send_usage_stats"] = { # noqa
"task": "apps.oss_installation.tasks.send_usage_stats_report",
@@ -724,5 +724,16 @@ PYROSCOPE_AUTH_TOKEN = os.getenv("PYROSCOPE_AUTH_TOKEN", "")
PHONE_PROVIDERS = {
"twilio": "apps.twilioapp.phone_provider.TwilioPhoneProvider",
# "simple": "apps.phone_notifications.simple_phone_provider.SimplePhoneProvider",
+ "zvonok": "apps.zvonok.phone_provider.ZvonokPhoneProvider",
}
PHONE_PROVIDER = os.environ.get("PHONE_PROVIDER", default="twilio")
+
+ZVONOK_API_KEY = os.getenv("ZVONOK_API_KEY", None)
+ZVONOK_CAMPAIGN_ID = os.getenv("ZVONOK_CAMPAIGN_ID", None)
+ZVONOK_AUDIO_ID = os.getenv("ZVONOK_AUDIO_ID", None)
+ZVONOK_SPEAKER_ID = os.getenv("ZVONOK_SPEAKER_ID", "Salli")
+ZVONOK_POSTBACK_CALL_ID = os.getenv("ZVONOK_POSTBACK_CALL_ID", "call_id")
+ZVONOK_POSTBACK_CAMPAIGN_ID = os.getenv("ZVONOK_POSTBACK_CAMPAIGN_ID", "campaign_id")
+ZVONOK_POSTBACK_STATUS = os.getenv("ZVONOK_POSTBACK_STATUS", "status")
+ZVONOK_POSTBACK_USER_CHOICE = os.getenv("ZVONOK_POSTBACK_USER_CHOICE", None)
+ZVONOK_POSTBACK_USER_CHOICE_ACK = os.getenv("ZVONOK_POSTBACK_USER_CHOICE_ACK", None)
diff --git a/grafana-plugin/.env.example b/grafana-plugin/.env.example
deleted file mode 100644
index 89211452..00000000
--- a/grafana-plugin/.env.example
+++ /dev/null
@@ -1,8 +0,0 @@
-# copy this file to ./.env and fill out the values according to your local setup
-
-# for integration test purposes
-BASE_URL=http://localhost:3000
-ONCALL_API_URL=http://host.docker.internal:8080/
-GRAFANA_USERNAME=oncall
-GRAFANA_PASSWORD=oncall
-IS_OPEN_SOURCE=True
diff --git a/grafana-plugin/integration-tests/.auth/.gitignore b/grafana-plugin/integration-tests/.auth/.gitignore
new file mode 100644
index 00000000..a6c57f5f
--- /dev/null
+++ b/grafana-plugin/integration-tests/.auth/.gitignore
@@ -0,0 +1 @@
+*.json
diff --git a/grafana-plugin/integration-tests/.env.example b/grafana-plugin/integration-tests/.env.example
new file mode 100644
index 00000000..530491bb
--- /dev/null
+++ b/grafana-plugin/integration-tests/.env.example
@@ -0,0 +1,9 @@
+BASE_URL=http://localhost:3000
+ONCALL_API_URL=http://host.docker.internal:8080/
+GRAFANA_VIEWER_USERNAME=viewer
+GRAFANA_VIEWER_PASSWORD=viewer
+GRAFANA_EDITOR_USERNAME=editor
+GRAFANA_EDITOR_PASSWORD=editor
+GRAFANA_ADMIN_USERNAME=oncall
+GRAFANA_ADMIN_PASSWORD=oncall
+IS_OPEN_SOURCE=True
diff --git a/grafana-plugin/integration-tests/alerts/onCallSchedule.test.ts b/grafana-plugin/integration-tests/alerts/onCallSchedule.test.ts
index 3ba425a9..7cb7686d 100644
--- a/grafana-plugin/integration-tests/alerts/onCallSchedule.test.ts
+++ b/grafana-plugin/integration-tests/alerts/onCallSchedule.test.ts
@@ -1,19 +1,20 @@
-import { test } from '@playwright/test';
+import { test } from '../fixtures';
import { verifyThatAlertGroupIsTriggered } from '../utils/alertGroup';
import { createEscalationChain, EscalationStep } from '../utils/escalationChain';
import { generateRandomValue } from '../utils/forms';
import { createIntegrationAndSendDemoAlert } from '../utils/integrations';
import { createOnCallSchedule } from '../utils/schedule';
-test('we can create an oncall schedule + receive an alert', async ({ page }) => {
+test('we can create an oncall schedule + receive an alert', async ({ adminRolePage }) => {
// this test does a lot of stuff, lets give it adequate time to do its thing
test.slow();
+ const { page, userName } = adminRolePage;
const escalationChainName = generateRandomValue();
const integrationName = generateRandomValue();
const onCallScheduleName = generateRandomValue();
- await createOnCallSchedule(page, onCallScheduleName);
+ await createOnCallSchedule(page, onCallScheduleName, userName);
await createEscalationChain(
page,
escalationChainName,
diff --git a/grafana-plugin/integration-tests/alerts/sms.test.ts b/grafana-plugin/integration-tests/alerts/sms.test.ts
index 070eef89..7da7f212 100644
--- a/grafana-plugin/integration-tests/alerts/sms.test.ts
+++ b/grafana-plugin/integration-tests/alerts/sms.test.ts
@@ -1,5 +1,4 @@
-import { test, expect } from '@playwright/test';
-import { GRAFANA_USERNAME } from '../utils/constants';
+import { test, expect } from '../fixtures';
import { createEscalationChain, EscalationStep } from '../utils/escalationChain';
import { generateRandomValue } from '../utils/forms';
import { createIntegrationAndSendDemoAlert } from '../utils/integrations';
@@ -7,14 +6,15 @@ import { waitForSms } from '../utils/phone';
import { configureUserNotificationSettings, verifyUserPhoneNumber } from '../utils/userSettings';
// TODO: enable once we've signed up for a MailSlurp account to receieve SMSes
-test.skip('we can verify our phone number + receive an SMS alert', async ({ page }) => {
+test.skip('we can verify our phone number + receive an SMS alert', async ({ adminRolePage }) => {
+ const { page, userName } = adminRolePage;
const escalationChainName = generateRandomValue();
const integrationName = generateRandomValue();
await verifyUserPhoneNumber(page);
await configureUserNotificationSettings(page, 'SMS');
- await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, GRAFANA_USERNAME);
+ await createEscalationChain(page, escalationChainName, EscalationStep.NotifyUsers, userName);
await createIntegrationAndSendDemoAlert(page, integrationName, escalationChainName);
// wait for the SMS alert notification to arrive
diff --git a/grafana-plugin/integration-tests/escalationChains/searching.test.ts b/grafana-plugin/integration-tests/escalationChains/searching.test.ts
index 5013f00f..4d61ae48 100644
--- a/grafana-plugin/integration-tests/escalationChains/searching.test.ts
+++ b/grafana-plugin/integration-tests/escalationChains/searching.test.ts
@@ -1,4 +1,4 @@
-import { test, expect, Page } from '@playwright/test';
+import { test, expect, Page } from '../fixtures';
import { generateRandomValue } from '../utils/forms';
import { createEscalationChain } from '../utils/escalationChain';
@@ -16,7 +16,9 @@ const assertEscalationChainSearchWorks = async (
};
// TODO: add tests for the new filtering. Commented out as this search doesn't exist anymore
-test.skip('searching allows case-insensitive partial matches', async ({ page }) => {
+test.skip('searching allows case-insensitive partial matches', async ({ adminRolePage }) => {
+ const { page } = adminRolePage;
+
const escalationChainName = `${generateRandomValue()} ${generateRandomValue()}`;
const [firstHalf, secondHalf] = escalationChainName.split(' ');
diff --git a/grafana-plugin/integration-tests/fixtures.ts b/grafana-plugin/integration-tests/fixtures.ts
new file mode 100644
index 00000000..79b2d020
--- /dev/null
+++ b/grafana-plugin/integration-tests/fixtures.ts
@@ -0,0 +1,53 @@
+import { test as base, Page } from '@playwright/test';
+
+import { GRAFANA_ADMIN_USERNAME, GRAFANA_EDITOR_USERNAME, GRAFANA_VIEWER_USERNAME } from './utils/constants';
+import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config';
+
+export class BaseRolePage {
+ page: Page;
+ userName: string;
+
+ constructor(page: Page) {
+ this.page = page;
+ }
+}
+
+class ViewerRolePage extends BaseRolePage {
+ userName = GRAFANA_VIEWER_USERNAME;
+}
+
+class EditorRolePage extends BaseRolePage {
+ userName = GRAFANA_EDITOR_USERNAME;
+}
+
+class AdminRolePage extends BaseRolePage {
+ userName = GRAFANA_ADMIN_USERNAME;
+}
+
+type Fixtures = {
+ viewerRolePage: ViewerRolePage;
+ editorRolePage: EditorRolePage;
+ adminRolePage: AdminRolePage;
+};
+
+export * from '@playwright/test';
+export const test = base.extend({
+ viewerRolePage: async ({ browser }, use) => {
+ const context = await browser.newContext({ storageState: VIEWER_USER_STORAGE_STATE });
+ const page = new ViewerRolePage(await context.newPage());
+ await use(page);
+ await context.close();
+ },
+ editorRolePage: async ({ browser }, use) => {
+ const context = await browser.newContext({ storageState: EDITOR_USER_STORAGE_STATE });
+ const page = new EditorRolePage(await context.newPage());
+ await use(page);
+ await context.close();
+ },
+ adminRolePage: async ({ browser }, use) => {
+ const context = await browser.newContext({ storageState: ADMIN_USER_STORAGE_STATE });
+ const page = new AdminRolePage(await context.newPage());
+ await use(page);
+ await context.close();
+ },
+});
diff --git a/grafana-plugin/integration-tests/globalSetup.ts b/grafana-plugin/integration-tests/globalSetup.ts
index 5881971a..c7c4227a 100644
--- a/grafana-plugin/integration-tests/globalSetup.ts
+++ b/grafana-plugin/integration-tests/globalSetup.ts
@@ -1,37 +1,57 @@
-import { test as setup, chromium, FullConfig, expect, Page, BrowserContext, APIResponse } from '@playwright/test';
+import { test as setup, chromium, expect, Page, BrowserContext, FullConfig, APIRequestContext } from '@playwright/test';
-import { BASE_URL, GRAFANA_PASSWORD, GRAFANA_USERNAME, IS_OPEN_SOURCE, ONCALL_API_URL } from './utils/constants';
+import GrafanaAPIClient from './utils/clients/grafana';
+import {
+ GRAFANA_ADMIN_PASSWORD,
+ GRAFANA_ADMIN_USERNAME,
+ GRAFANA_EDITOR_PASSWORD,
+ GRAFANA_EDITOR_USERNAME,
+ GRAFANA_VIEWER_PASSWORD,
+ GRAFANA_VIEWER_USERNAME,
+ IS_CLOUD,
+ IS_OPEN_SOURCE,
+ ONCALL_API_URL,
+} from './utils/constants';
import { clickButton, getInputByName } from './utils/forms';
import { goToGrafanaPage } from './utils/navigation';
-import { STORAGE_STATE } from '../playwright.config';
+import { VIEWER_USER_STORAGE_STATE, EDITOR_USER_STORAGE_STATE, ADMIN_USER_STORAGE_STATE } from '../playwright.config';
+import { OrgRole } from '@grafana/data';
-const IS_CLOUD = !IS_OPEN_SOURCE;
-const GLOBAL_SETUP_RETRIES = 3;
+const grafanaApiClient = new GrafanaAPIClient(GRAFANA_ADMIN_USERNAME, GRAFANA_ADMIN_PASSWORD);
-const makeGrafanaLoginRequest = async (browserContext: BrowserContext): Promise =>
- browserContext.request.post(`${BASE_URL}/login`, {
- data: {
- user: GRAFANA_USERNAME,
- password: GRAFANA_PASSWORD,
- },
- });
+type UserCreationSettings = {
+ adminAuthedRequest: APIRequestContext;
+ role: OrgRole;
+};
-const pollGrafanaInstanceUntilItIsHealthy = async (browserContext: BrowserContext): Promise => {
- console.log('Polling the grafana instance to make sure it is healthy');
-
- const res = await makeGrafanaLoginRequest(browserContext);
-
- if (!res.ok()) {
- console.log(`Grafana instance is unavailable. Got HTTP ${res.status()}. Will wait 5 seconds and then try again`);
- await new Promise((resolve) => setTimeout(resolve, 5000));
- return pollGrafanaInstanceUntilItIsHealthy(browserContext);
+const generateLoginStorageStateAndOptionallCreateUser = async (
+ config: FullConfig,
+ userName: string,
+ password: string,
+ storageStateFileLocation: string,
+ userCreationSettings?: UserCreationSettings,
+ closeContext = false
+): Promise => {
+ if (userCreationSettings !== undefined && IS_OPEN_SOURCE) {
+ const { adminAuthedRequest, role } = userCreationSettings;
+ await grafanaApiClient.idempotentlyCreateUserWithRole(adminAuthedRequest, userName, password, role);
}
- console.log('Grafana instance is available');
- return true;
+
+ const { headless } = config.projects[0]!.use;
+ const browser = await chromium.launch({ headless, slowMo: headless ? 0 : 100 });
+ const browserContext = await browser.newContext();
+
+ await grafanaApiClient.login(browserContext.request, userName, password);
+ await browserContext.storageState({ path: storageStateFileLocation });
+
+ if (closeContext) {
+ await browserContext.close();
+ }
+ return browserContext;
};
/**
- * go to config page and wait for plugin icon to be available on left-hand navigation
+ go to config page and wait for plugin icon to be available on left-hand navigation
*/
const configureOnCallPlugin = async (page: Page): Promise => {
/**
@@ -66,55 +86,7 @@ const configureOnCallPlugin = async (page: Page): Promise => {
* Borrowed from our friends on the Incident team
* https://github.com/grafana/incident/blob/main/plugin/e2e/global-setup.ts
*/
-const globalSetup = async (config: FullConfig): Promise => {
- const { headless } = config.projects[0]!.use;
- const browser = await chromium.launch({ headless, slowMo: headless ? 0 : 100 });
- const browserContext = await browser.newContext();
-
- if (IS_CLOUD) {
- /**
- * check that the grafana instance is available. If HTTP 503 is returned it means the
- * instance is currently unavailable. Poll until it is available
- */
- await pollGrafanaInstanceUntilItIsHealthy(browserContext);
- }
-
- const res = await makeGrafanaLoginRequest(browserContext);
-
- expect(res.ok()).toBeTruthy();
- await browserContext.storageState({ path: STORAGE_STATE });
-
- // make sure the plugin has been configured
- const page = await browserContext.newPage();
-
- if (IS_OPEN_SOURCE) {
- // plugin configuration can safely be skipped for cloud environments
- await configureOnCallPlugin(page);
- }
-
- await browserContext.close();
-};
-
-/**
- * Let's retry global setup, in the event that it fails due to an oncall-engine/oncall-celery backend error.
- * Sometimes the sync endpoint will randomly return HTTP 500.
- * See here for an example CI job which failed global setup
- * https://github.com/grafana/oncall/actions/runs/5062712137/jobs/9088529416#step:19:2536
- *
- * References on retrying playwright global setup
- * https://github.com/microsoft/playwright/discussions/11371
- */
-const globalSetupWithRetries = async (config: FullConfig): Promise => {
- for (let i = 0; i < GLOBAL_SETUP_RETRIES - 1; i++) {
- try {
- return await globalSetup(config);
- } catch (e) {}
- }
- // One last time, throwing an error if it fails.
- await globalSetup(config);
-};
-
-setup('Configure Grafana OnCall plugin', async ({}, { config }) => {
+setup('Configure Grafana OnCall plugin', async ({ request }, { config }) => {
/**
* Unconditionally marks the setup as "slow", giving it triple the default timeout.
* This is mostly useful for the rare case for Cloud Grafana instances where the instance may be down/unavailable
@@ -122,5 +94,47 @@ setup('Configure Grafana OnCall plugin', async ({}, { config }) => {
*/
setup.slow();
- await globalSetupWithRetries(config);
+ if (IS_CLOUD) {
+ await grafanaApiClient.pollInstanceUntilItIsHealthy(request);
+ }
+
+ const adminBrowserContext = await generateLoginStorageStateAndOptionallCreateUser(
+ config,
+ GRAFANA_ADMIN_USERNAME,
+ GRAFANA_ADMIN_PASSWORD,
+ ADMIN_USER_STORAGE_STATE
+ );
+ const adminPage = await adminBrowserContext.newPage();
+ const { request: adminAuthedRequest } = adminBrowserContext;
+
+ await generateLoginStorageStateAndOptionallCreateUser(
+ config,
+ GRAFANA_EDITOR_USERNAME,
+ GRAFANA_EDITOR_PASSWORD,
+ EDITOR_USER_STORAGE_STATE,
+ {
+ adminAuthedRequest,
+ role: OrgRole.Editor,
+ },
+ true
+ );
+
+ await generateLoginStorageStateAndOptionallCreateUser(
+ config,
+ GRAFANA_VIEWER_USERNAME,
+ GRAFANA_VIEWER_PASSWORD,
+ VIEWER_USER_STORAGE_STATE,
+ {
+ adminAuthedRequest,
+ role: OrgRole.Viewer,
+ },
+ true
+ );
+
+ if (IS_OPEN_SOURCE) {
+ // plugin configuration can safely be skipped for cloud environments
+ await configureOnCallPlugin(adminPage);
+ }
+
+ await adminBrowserContext.close();
});
diff --git a/grafana-plugin/integration-tests/integrations/uniqueIntegrationNames.test.ts b/grafana-plugin/integration-tests/integrations/uniqueIntegrationNames.test.ts
index e3c8df80..be27aa86 100644
--- a/grafana-plugin/integration-tests/integrations/uniqueIntegrationNames.test.ts
+++ b/grafana-plugin/integration-tests/integrations/uniqueIntegrationNames.test.ts
@@ -1,7 +1,8 @@
-import { test, expect } from '@playwright/test';
+import { test, expect } from '../fixtures';
import { openCreateIntegrationModal } from '../utils/integrations';
-test('integrations have unique names', async ({ page }) => {
+test('integrations have unique names', async ({ adminRolePage }) => {
+ const { page } = adminRolePage;
await openCreateIntegrationModal(page);
const integrationNames = await page.getByTestId('integration-display-name').allInnerTexts();
diff --git a/grafana-plugin/integration-tests/schedules/addOverride.test.ts b/grafana-plugin/integration-tests/schedules/addOverride.test.ts
index f76b6ef9..2ee99494 100644
--- a/grafana-plugin/integration-tests/schedules/addOverride.test.ts
+++ b/grafana-plugin/integration-tests/schedules/addOverride.test.ts
@@ -1,11 +1,13 @@
-import { test, expect } from '@playwright/test';
+import { test, expect } from '../fixtures';
import { clickButton, generateRandomValue } from '../utils/forms';
import { createOnCallSchedule, getOverrideFormDateInputs } from '../utils/schedule';
import dayjs from 'dayjs';
-test('default dates in override creation modal are correct', async ({ page }) => {
+test('default dates in override creation modal are correct', async ({ adminRolePage }) => {
+ const { page, userName } = adminRolePage;
+
const onCallScheduleName = generateRandomValue();
- await createOnCallSchedule(page, onCallScheduleName);
+ await createOnCallSchedule(page, onCallScheduleName, userName);
await clickButton({ page, buttonText: 'Add override' });
diff --git a/grafana-plugin/integration-tests/schedules/quality.test.ts b/grafana-plugin/integration-tests/schedules/quality.test.ts
index 4e010162..bcf55442 100644
--- a/grafana-plugin/integration-tests/schedules/quality.test.ts
+++ b/grafana-plugin/integration-tests/schedules/quality.test.ts
@@ -1,10 +1,12 @@
-import { test, expect } from '@playwright/test';
+import { test, expect } from '../fixtures';
import { generateRandomValue } from '../utils/forms';
import { createOnCallSchedule } from '../utils/schedule';
-test('check schedule quality for simple 1-user schedule', async ({ page }) => {
+test('check schedule quality for simple 1-user schedule', async ({ adminRolePage }) => {
+ const { page, userName } = adminRolePage;
const onCallScheduleName = generateRandomValue();
- await createOnCallSchedule(page, onCallScheduleName);
+
+ await createOnCallSchedule(page, onCallScheduleName, userName);
/**
* this page.reload() call is a hack to temporarily get around this issue
diff --git a/grafana-plugin/integration-tests/users/viewUsers.test.ts b/grafana-plugin/integration-tests/users/viewUsers.test.ts
new file mode 100644
index 00000000..f545097a
--- /dev/null
+++ b/grafana-plugin/integration-tests/users/viewUsers.test.ts
@@ -0,0 +1,34 @@
+import { test, expect, Page } from '../fixtures';
+import { goToOnCallPage } from '../utils/navigation';
+
+test.describe('view list of users', () => {
+ const testFlow = async (page: Page, isAllowedToView = true): Promise => {
+ await goToOnCallPage(page, 'users');
+
+ if (isAllowedToView) {
+ const usersTableElement = page.getByTestId('users-table');
+ await usersTableElement.waitFor({ state: 'visible' });
+
+ const userRowsContext = await usersTableElement.locator('tbody > tr').allTextContents();
+ expect(userRowsContext.length).toBeGreaterThan(0);
+ } else {
+ const missingPermissionsMessageElement = page.getByTestId('view-users-missing-permission-message');
+ await missingPermissionsMessageElement.waitFor({ state: 'visible' });
+
+ const missingPermissionMessage = await missingPermissionsMessageElement.textContent();
+ expect(missingPermissionMessage).toMatch(/You are missing the .* to be able to view OnCall users/);
+ }
+ };
+
+ test('admin is allowed to', async ({ adminRolePage }) => {
+ await testFlow(adminRolePage.page);
+ });
+
+ test('editor is allowed to', async ({ editorRolePage }) => {
+ await testFlow(editorRolePage.page);
+ });
+
+ test('viewer is not allowed to', async ({ viewerRolePage }) => {
+ await testFlow(viewerRolePage.page, false);
+ });
+});
diff --git a/grafana-plugin/integration-tests/utils/clients/grafana.ts b/grafana-plugin/integration-tests/utils/clients/grafana.ts
new file mode 100644
index 00000000..21274100
--- /dev/null
+++ b/grafana-plugin/integration-tests/utils/clients/grafana.ts
@@ -0,0 +1,116 @@
+import { OrgRole } from '@grafana/data';
+import { expect, APIRequestContext } from '@playwright/test';
+
+import { BASE_URL } from '../constants';
+
+type UsersLookupResponse = {
+ id: number;
+};
+
+type CreateUserResponse = {
+ id: number;
+};
+
+class GrafanaApiException extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = this.constructor.name;
+ }
+}
+
+export default class GrafanaAPIClient {
+ userName: string;
+ password: string;
+
+ constructor(userName: string, password: string) {
+ this.userName = userName;
+ this.password = password;
+ }
+
+ get requestHeaders() {
+ const base64encodedCredentials = Buffer.from(`${this.userName}:${this.password}`).toString('base64');
+ return {
+ Authorization: `Basic ${base64encodedCredentials}`,
+ };
+ }
+
+ /**
+ * check that the grafana instance is available. If HTTP 503 is returned it means the
+ * instance is currently unavailable. Poll until it is available
+ */
+ pollInstanceUntilItIsHealthy = async (request: APIRequestContext): Promise => {
+ console.log('Polling the grafana instance to make sure it is healthy');
+
+ const res = await request.get(`${BASE_URL}/api/health`);
+
+ if (!res.ok()) {
+ console.log(`Grafana instance is unavailable. Got HTTP ${res.status()}. Will wait 5 seconds and then try again`);
+ await new Promise((resolve) => setTimeout(resolve, 5000));
+ return this.pollInstanceUntilItIsHealthy(request);
+ }
+ console.log('Grafana instance is available');
+ return true;
+ };
+
+ getUserIdByUsername = async (request: APIRequestContext, userName: string): Promise => {
+ const res = await request.get(`${BASE_URL}/api/users/lookup?loginOrEmail=${userName}`, {
+ headers: this.requestHeaders,
+ });
+ expect(res.ok()).toBeTruthy();
+ const responseData: UsersLookupResponse = await res.json();
+ return responseData.id;
+ };
+
+ updateUserRole = async (request: APIRequestContext, userId: number, role: OrgRole): Promise => {
+ const res = await request.patch(`${BASE_URL}/api/org/users/${userId}`, {
+ data: { role },
+ headers: this.requestHeaders,
+ });
+ expect(res.ok()).toBeTruthy();
+ };
+
+ /**
+ * Should return one of the following two responses:
+ * - HTTP 200 - user successfully created
+ * - HTTP 412 - user w/ this username already exists (fine to ignore this)
+ */
+ idempotentlyCreateUserWithRole = async (
+ request: APIRequestContext,
+ userName: string,
+ password: string,
+ role: OrgRole
+ ) => {
+ const res = await request.post(`${BASE_URL}/api/admin/users`, {
+ data: {
+ name: `e2e user - ${userName}`,
+ login: userName,
+ password,
+ },
+ });
+
+ let userId: number;
+ const responseCode = res.status();
+
+ if (responseCode === 200) {
+ // user was just created
+ const respJson: CreateUserResponse = await res.json();
+ userId = respJson.id;
+ } else if (responseCode == 412) {
+ // user already exists, go fetch their user id
+ userId = await this.getUserIdByUsername(request, userName);
+ } else {
+ throw new GrafanaApiException(
+ `Received unexpected status code while trying to idempotently create user - HTTP${responseCode}: ${await res.body()}`
+ );
+ }
+
+ await this.updateUserRole(request, userId, role);
+ };
+
+ login = async (request: APIRequestContext, userName: string, password: string) => {
+ const res = await request.post(`${BASE_URL}/login`, {
+ data: { user: userName, password },
+ });
+ expect(res.ok()).toBeTruthy();
+ };
+}
diff --git a/grafana-plugin/integration-tests/utils/constants.ts b/grafana-plugin/integration-tests/utils/constants.ts
index 7e9a5998..97fcd3b7 100644
--- a/grafana-plugin/integration-tests/utils/constants.ts
+++ b/grafana-plugin/integration-tests/utils/constants.ts
@@ -1,6 +1,13 @@
export const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
export const ONCALL_API_URL = process.env.ONCALL_API_URL || 'http://host.docker.internal:8080';
-export const GRAFANA_USERNAME = process.env.GRAFANA_USERNAME || 'oncall';
-export const GRAFANA_PASSWORD = process.env.GRAFANA_PASSWORD || 'oncall';
export const MAILSLURP_API_KEY = process.env.MAILSLURP_API_KEY;
+
+export const GRAFANA_VIEWER_USERNAME = process.env.GRAFANA_VIEWER_USERNAME || 'viewer';
+export const GRAFANA_VIEWER_PASSWORD = process.env.GRAFANA_VIEWER_PASSWORD || 'viewer';
+export const GRAFANA_EDITOR_USERNAME = process.env.GRAFANA_EDITOR_USERNAME || 'editor';
+export const GRAFANA_EDITOR_PASSWORD = process.env.GRAFANA_EDITOR_PASSWORD || 'editor';
+export const GRAFANA_ADMIN_USERNAME = process.env.GRAFANA_ADMIN_USERNAME || 'oncall';
+export const GRAFANA_ADMIN_PASSWORD = process.env.GRAFANA_ADMIN_PASSWORD || 'oncall';
+
export const IS_OPEN_SOURCE = (process.env.IS_OPEN_SOURCE || 'true').toLowerCase() === 'true';
+export const IS_CLOUD = !IS_OPEN_SOURCE;
diff --git a/grafana-plugin/integration-tests/utils/schedule.ts b/grafana-plugin/integration-tests/utils/schedule.ts
index d3106807..9c30c746 100644
--- a/grafana-plugin/integration-tests/utils/schedule.ts
+++ b/grafana-plugin/integration-tests/utils/schedule.ts
@@ -1,10 +1,9 @@
import { Page } from '@playwright/test';
-import { GRAFANA_USERNAME } from './constants';
-import { clickButton, fillInInput, selectDropdownValue, selectValuePickerValue } from './forms';
+import { clickButton, fillInInput, selectDropdownValue } from './forms';
import { goToOnCallPage } from './navigation';
import dayjs from 'dayjs';
-export const createOnCallSchedule = async (page: Page, scheduleName: string): Promise => {
+export const createOnCallSchedule = async (page: Page, scheduleName: string, userName: string): Promise => {
// go to the schedules page
await goToOnCallPage(page, 'schedules');
@@ -24,7 +23,7 @@ export const createOnCallSchedule = async (page: Page, scheduleName: string): Pr
page,
selectType: 'grafanaSelect',
placeholderText: 'Add user',
- value: GRAFANA_USERNAME,
+ value: userName,
});
await clickButton({ page, buttonText: 'Create' });
diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts
index 8ea67ebc..bab2ea80 100644
--- a/grafana-plugin/playwright.config.ts
+++ b/grafana-plugin/playwright.config.ts
@@ -7,9 +7,11 @@ import { devices } from '@playwright/test';
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
-require('dotenv').config();
+require('dotenv').config({ path: path.resolve(process.cwd(), 'integration-tests/.env') });
-export const STORAGE_STATE = path.join(__dirname, 'integration-tests/storageState.json');
+export const VIEWER_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/.auth/viewer.json');
+export const EDITOR_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/.auth/editor.json');
+export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/.auth/admin.json');
/**
* See https://playwright.dev/docs/test-configuration.
@@ -62,7 +64,6 @@ const config: PlaywrightTestConfig = {
name: 'chromium',
use: {
...devices['Desktop Chrome'],
- storageState: STORAGE_STATE,
},
dependencies: ['setup'],
},
@@ -70,7 +71,6 @@ const config: PlaywrightTestConfig = {
name: 'firefox',
use: {
...devices['Desktop Firefox'],
- storageState: STORAGE_STATE,
},
dependencies: ['setup'],
},
@@ -78,7 +78,6 @@ const config: PlaywrightTestConfig = {
name: 'webkit',
use: {
...devices['Desktop Safari'],
- storageState: STORAGE_STATE,
},
dependencies: ['setup'],
},
diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx
index 26ed1353..d0d8f004 100644
--- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx
+++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx
@@ -196,7 +196,7 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
{content}
- {store.hasFeature(AppFeature.MobileTestPush) && mobileAppIsCurrentlyConnected && isCurrentUser && (
+ {mobileAppIsCurrentlyConnected && isCurrentUser && (
{
profile
>
}
+ data-testid="view-users-missing-permission-message"
severity="info"
/>
)}
diff --git a/grafana-plugin/src/state/features.ts b/grafana-plugin/src/state/features.ts
index 1b5c8bf0..624e2761 100644
--- a/grafana-plugin/src/state/features.ts
+++ b/grafana-plugin/src/state/features.ts
@@ -6,5 +6,4 @@ export enum AppFeature {
CloudConnection = 'grafana_cloud_connection',
WebSchedules = 'web_schedules',
Webhooks2 = 'webhooks2',
- MobileTestPush = 'mobile_test_push',
}