Merge pull request #2424 from grafana/dev

dev to main
This commit is contained in:
Innokentii Konstantinov 2023-07-05 15:03:23 +08:00 committed by GitHub
commit d0f415cfd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 887 additions and 141 deletions

View file

@ -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
...

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",
}
]

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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"

View file

@ -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)

View file

@ -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):

View file

@ -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

View file

View file

@ -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,
},
),
]

View file

@ -0,0 +1 @@
from .phone_call import ZvonokCallStatuses, ZvonokPhoneCall # noqa: F401

View file

@ -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,
)

View file

@ -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'<audio id="{live_settings.ZVONOK_AUDIO_ID}"/>'
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,
)

View file

@ -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)

View file

@ -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"),
]

View file

@ -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)

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -0,0 +1 @@
*.json

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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(' ');

View file

@ -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<Fixtures>({
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();
},
});

View file

@ -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<APIResponse> =>
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<boolean> => {
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<BrowserContext> => {
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<void> => {
/**
@ -66,55 +86,7 @@ const configureOnCallPlugin = async (page: Page): Promise<void> => {
* 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<void> => {
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<void> => {
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();
});

View file

@ -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();

View file

@ -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' });

View file

@ -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

View file

@ -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<void> => {
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);
});
});

View file

@ -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<boolean> => {
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<number> => {
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<void> => {
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();
};
}

View file

@ -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;

View file

@ -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<void> => {
export const createOnCallSchedule = async (page: Page, scheduleName: string, userName: string): Promise<void> => {
// 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' });

View file

@ -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'],
},

View file

@ -196,7 +196,7 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
{content}
</Block>
</div>
{store.hasFeature(AppFeature.MobileTestPush) && mobileAppIsCurrentlyConnected && isCurrentUser && (
{mobileAppIsCurrentlyConnected && isCurrentUser && (
<div className={cx('notification-buttons')}>
<HorizontalGroup spacing={'md'} justify={'flex-end'}>
<Button

View file

@ -224,6 +224,7 @@ class Users extends React.Component<UsersProps, UsersState> {
</div>
<GTable
data-testid="users-table"
emptyText={initialUsersLoaded ? 'No users found' : 'Loading...'}
rowKey="pk"
data={results}
@ -246,6 +247,7 @@ class Users extends React.Component<UsersProps, UsersState> {
profile
</>
}
data-testid="view-users-missing-permission-message"
severity="info"
/>
)}

View file

@ -6,5 +6,4 @@ export enum AppFeature {
CloudConnection = 'grafana_cloud_connection',
WebSchedules = 'web_schedules',
Webhooks2 = 'webhooks2',
MobileTestPush = 'mobile_test_push',
}