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'