This commit is contained in:
Joey Orlando 2024-04-02 16:22:48 -04:00 committed by GitHub
commit c51141451b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 1953 additions and 196 deletions

View file

@ -29,7 +29,7 @@ repos:
files: ^dev/scripts
- repo: https://github.com/pycqa/flake8
rev: 6.0.0
rev: 7.0.0
hooks:
- id: flake8
files: ^engine

View file

@ -0,0 +1,19 @@
# Generated by Django 4.2.11 on 2024-04-02 13:54
from django.db import migrations
import django_migration_linter as linter
class Migration(migrations.Migration):
dependencies = [
('alerts', '0050_alter_alertgrouplogrecord_type'),
]
operations = [
linter.IgnoreMigration(),
migrations.RemoveField(
model_name='escalationpolicy',
name='custom_button_trigger',
),
]

View file

@ -1889,11 +1889,11 @@ class AlertGroup(AlertGroupSlackRenderingMixin, EscalationSnapshotMixin, models.
result_log_report = list()
for log_record in log_records_list:
if type(log_record) == AlertGroupLogRecord:
if type(log_record) is AlertGroupLogRecord:
result_log_report.append(log_record.render_log_line_json())
elif type(log_record) == UserNotificationPolicyLogRecord:
elif type(log_record) is UserNotificationPolicyLogRecord:
result_log_report.append(log_record.rendered_notification_log_line_json)
elif type(log_record) == ResolutionNote:
elif type(log_record) is ResolutionNote:
result_log_report.append(log_record.render_log_line_json())
return result_log_report

View file

@ -3,7 +3,6 @@ import datetime
from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models
from django_deprecate_fields import deprecate_field
from common.ordered_model.ordered_model import OrderedModel
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
@ -281,17 +280,6 @@ class EscalationPolicy(OrderedModel):
default=None,
)
# TODO: remove this in a subsequent release
custom_button_trigger = deprecate_field(
models.ForeignKey(
"alerts.CustomButton",
on_delete=models.CASCADE,
related_name="escalation_policies",
default=None,
null=True,
)
)
custom_webhook = models.ForeignKey(
"webhooks.Webhook",
on_delete=models.CASCADE,

View file

@ -1,6 +1,7 @@
import typing
from apps.user_management.constants import AlertGroupTableColumns, default_columns
from apps.user_management.constants import default_columns
from apps.user_management.types import AlertGroupTableColumns
if typing.TYPE_CHECKING:
from apps.user_management.models import User

View file

@ -31,6 +31,25 @@ class UserPermissionSerializer(serializers.Serializer):
action = serializers.CharField(read_only=True)
class GoogleCalendarSettingsSerializer(serializers.Serializer):
# # TODO: figure out how to get OrganizationFilteredPrimaryKeyRelatedField to work with many=True
# oncall_schedules_to_consider_for_shift_swaps =
# oncall_schedules_to_consider_for_shift_swaps = serializers.ListField(
# child=OrganizationFilteredPrimaryKeyRelatedField(
# queryset=OnCallSchedule.objects,
# required=False,
# allow_null=True,
# ),
# required=False,
# allow_null=True,
# )
oncall_schedules_to_consider_for_shift_swaps = serializers.ListField(
child=serializers.CharField(),
required=False,
allow_null=True,
)
class NotificationChainVerbal(typing.TypedDict):
default: str
important: str
@ -93,6 +112,7 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
"notification_chain_verbal",
"cloud_connection_status",
"hide_phone_number",
"has_google_oauth2_connected",
]
read_only_fields = [
"email",
@ -100,6 +120,7 @@ class ListUserSerializer(DynamicFieldsModelSerializer, EagerLoadingMixin):
"name",
"role",
"verified_phone_number",
"has_google_oauth2_connected",
]
def validate_working_hours(self, working_hours):
@ -169,10 +190,12 @@ class UserSerializer(ListUserSerializer):
context: UserSerializerContext
is_currently_oncall = serializers.SerializerMethodField()
google_calendar_settings = GoogleCalendarSettingsSerializer(required=False)
class Meta(ListUserSerializer.Meta):
fields = ListUserSerializer.Meta.fields + [
"is_currently_oncall",
"google_calendar_settings",
]
read_only_fields = ListUserSerializer.Meta.read_only_fields + [
"is_currently_oncall",

View file

@ -145,10 +145,10 @@ def test_list_alert_receive_channel_skip_pagination_for_grafana_alerting(
assert response.status_code == status.HTTP_200_OK
if should_be_unpaginated:
assert type(results) == list
assert type(results) is list
assert len(results) > 0
else:
assert type(results["results"]) == list
assert type(results["results"]) is list
assert len(results["results"]) > 0

View file

@ -3,6 +3,7 @@ from unittest.mock import patch
import pytest
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.http import HttpResponse
from django.test.utils import override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
@ -31,7 +32,7 @@ def test_complete_slack_auth_redirect_ok(
client = APIClient()
url = (
reverse("api-internal:complete-slack-auth", kwargs={"backend": backend_name})
reverse("api-internal:complete-social-auth", kwargs={"backend": backend_name})
+ f"?{SLACK_AUTH_TOKEN_NAME}={slack_token}"
)
@ -55,7 +56,7 @@ def test_complete_slack_auth_redirect_error(
client = APIClient()
url = (
reverse("api-internal:complete-slack-auth", kwargs={"backend": "slack-login"})
reverse("api-internal:complete-social-auth", kwargs={"backend": "slack-login"})
+ f"?{SLACK_AUTH_TOKEN_NAME}={slack_token}"
)
@ -68,3 +69,56 @@ def test_complete_slack_auth_redirect_error(
assert response.status_code == status.HTTP_302_FOUND
assert response.url == "some-url"
@pytest.mark.django_db
@patch("apps.social_auth.backends.GoogleOAuth2.get_redirect_uri")
@patch("apps.social_auth.backends.GoogleOAuth2Token.create_auth_token", return_value=("something", "token_string"))
@override_settings(SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE=["https://www.googleapis.com/auth/calendar.events.readonly"])
@override_settings(SOCIAL_AUTH_GOOGLE_OAUTH2_KEY="ouath2_key")
def test_google_start_auth_redirect_ok(
_mock_create_google_oauth2_auth_token,
mock_google_oauth2_backend_get_redirect_uri,
make_organization_and_user_with_plugin_token,
make_user_auth_headers,
):
redirect_uri = "http://testserver"
mock_google_oauth2_backend_get_redirect_uri.return_value = redirect_uri
_, user, token = make_organization_and_user_with_plugin_token()
client = APIClient()
url = reverse("api-internal:social-auth", kwargs={"backend": "google-oauth2"})
response = client.get(url, **make_user_auth_headers(user, token))
assert response.status_code == status.HTTP_200_OK
assert response.json() == (
"https://accounts.google.com/o/oauth2/auth?client_id=ouath2_key"
f"&redirect_uri={redirect_uri}&response_type=code"
"&state=token_string&scope=https://www.googleapis.com/auth/calendar.events.readonly+openid+email+profile"
"&access_type=offline&approval_prompt=auto"
)
@pytest.mark.django_db
@patch("apps.api.views.auth.do_complete", return_value=None)
def test_google_complete_auth_redirect_ok(
_mock_do_complete,
make_organization,
make_user_for_organization,
make_google_oauth2_token_for_user,
):
organization = make_organization()
admin = make_user_for_organization(organization)
_, google_oauth2_token = make_google_oauth2_token_for_user(admin)
client = APIClient()
url = (
reverse("api-internal:complete-social-auth", kwargs={"backend": "google-oauth2"})
+ f"?state={google_oauth2_token}"
)
response = client.get(url)
assert response.status_code == status.HTTP_302_FOUND
assert response.url == "/a/grafana-oncall-app/users/me"

View file

@ -176,8 +176,8 @@ def test_retrieve_permissions(
assert response.status_code == expected_status
@patch("apps.api.views.shift_swap.write_resource_insight_log")
@patch("apps.api.views.shift_swap.create_shift_swap_request_message")
@patch("apps.schedules.models.shift_swap_request.write_resource_insight_log")
@patch("apps.schedules.tasks.shift_swaps.create_shift_swap_request_message")
@pytest.mark.django_db
def test_create(
mock_create_shift_swap_request_message,

View file

@ -63,6 +63,8 @@ def test_current_user(
"slack_user_identity": None,
"avatar": user.avatar_url,
"avatar_full": user.avatar_full_url,
"has_google_oauth2_connected": False,
"google_calendar_settings": None,
}
response = client.get(url, format="json", **make_user_auth_headers(user, token))
@ -126,6 +128,44 @@ def test_update_user(
assert response.json()["current_team"] == data["current_team"]
@pytest.mark.parametrize("oncall_schedules_to_consider_for_shift_swaps", [True, False])
@pytest.mark.django_db
def test_update_user_google_calendar_settings(
make_organization,
make_user_for_organization,
make_token_for_organization,
make_user_auth_headers,
make_schedule,
oncall_schedules_to_consider_for_shift_swaps,
):
organization = make_organization()
admin = make_user_for_organization(organization)
_, token = make_token_for_organization(organization)
schedule1 = make_schedule(organization, schedule_class=OnCallScheduleWeb)
schedule2 = make_schedule(organization, schedule_class=OnCallScheduleWeb)
client = APIClient()
url = reverse("api-internal:user-detail", kwargs={"pk": admin.public_primary_key})
schedule_public_primary_keys = (
[schedule1.public_primary_key, schedule2.public_primary_key]
if oncall_schedules_to_consider_for_shift_swaps
else []
)
data = {
"google_calendar_settings": {
"oncall_schedules_to_consider_for_shift_swaps": schedule_public_primary_keys,
},
}
response = client.put(url, data, format="json", **make_user_auth_headers(admin, token))
assert response.status_code == status.HTTP_200_OK
assert response.json()["google_calendar_settings"] == {
"oncall_schedules_to_consider_for_shift_swaps": schedule_public_primary_keys,
}
@override_settings(GRAFANA_CLOUD_NOTIFICATIONS_ENABLED=False)
@pytest.mark.django_db
def test_update_user_cant_change_email_and_username(
@ -171,6 +211,8 @@ def test_update_user_cant_change_email_and_username(
"slack_user_identity": None,
"avatar": admin.avatar_url,
"avatar_full": admin.avatar_full_url,
"has_google_oauth2_connected": False,
"google_calendar_settings": None,
}
response = client.put(url, data, format="json", **make_user_auth_headers(admin, token))
assert response.status_code == status.HTTP_200_OK
@ -222,6 +264,7 @@ def test_list_users(
"avatar": admin.avatar_url,
"avatar_full": admin.avatar_full_url,
"cloud_connection_status": None,
"has_google_oauth2_connected": False,
},
{
"pk": editor.public_primary_key,
@ -247,6 +290,7 @@ def test_list_users(
"avatar": editor.avatar_url,
"avatar_full": editor.avatar_full_url,
"cloud_connection_status": None,
"has_google_oauth2_connected": False,
},
],
"current_page_number": 1,

View file

@ -106,9 +106,12 @@ urlpatterns = [
urlpatterns += [
# For some reason frontend is using url without / at the end. Hacking here to avoid 301's :(
path(r"login/<backend>", auth.overridden_login_slack_auth, name="slack-auth-with-no-slash"),
path(r"login/<backend>/", auth.overridden_login_slack_auth, name="slack-auth"),
path(r"complete/<backend>/", auth.overridden_complete_slack_auth, name="complete-slack-auth"),
# TODO: I'm fairly certain this is not needed anymore, it looks like the frontend instead
# makes calls w/ a trailing slash.. we can probably get rid of social-auth-with-no-slash
path(r"login/<backend>", auth.overridden_login_social_auth, name="social-auth-with-no-slash"),
path(r"login/<backend>/", auth.overridden_login_social_auth, name="social-auth"),
path(r"complete/<backend>/", auth.overridden_complete_social_auth, name="complete-social-auth"),
path(r"disconnect/<backend>", auth.overridden_disconnect_social_auth, name="disconnect-social-auth"),
]
urlpatterns += [

View file

@ -12,7 +12,8 @@ from apps.api.serializers.alert_group_table_settings import (
)
from apps.api.views.labels import LabelsFeatureFlagViewSet
from apps.auth_token.auth import PluginAuthentication
from apps.user_management.constants import AlertGroupTableColumn, default_columns
from apps.user_management.constants import default_columns
from apps.user_management.types import AlertGroupTableColumn
class AlertGroupTableColumnsViewSet(LabelsFeatureFlagViewSet):

View file

@ -443,7 +443,7 @@ class AlertReceiveChannelView(
if payload is None:
return channel.alert_groups.last().alerts.first()
else:
if type(payload) != dict:
if type(payload) is not dict:
raise PreviewTemplateException("Payload must be a valid json object")
# Build Alert and AlertGroup objects to pass to templater without saving them to db
alert_group_to_template = AlertGroup(channel=channel)
@ -529,7 +529,7 @@ class AlertReceiveChannelView(
try:
instance.start_maintenance(mode, duration, request.user)
except MaintenanceCouldNotBeStartedError as e:
if type(instance) == AlertReceiveChannel:
if type(instance) is AlertReceiveChannel:
detail = {"alert_receive_channel_id": ["Already on maintenance"]}
else:
detail = str(e)

View file

@ -7,12 +7,14 @@ from django.http import HttpResponse, HttpResponseRedirect
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, authentication_classes
from rest_framework.request import Request
from rest_framework.response import Response
from social_core.actions import do_auth, do_complete
from social_core.actions import do_auth, do_complete, do_disconnect
from social_core.backends.google import GoogleOAuth2
from social_django.utils import psa
from social_django.views import _do_login
from apps.auth_token.auth import PluginAuthentication, SlackTokenAuthentication
from apps.auth_token.auth import GoogleTokenAuthentication, PluginAuthentication, SlackTokenAuthentication
from apps.social_auth.backends import LoginSlackOAuth2V2
logger = logging.getLogger(__name__)
@ -22,32 +24,33 @@ logger = logging.getLogger(__name__)
@authentication_classes([PluginAuthentication])
@never_cache
@psa("social:complete")
def overridden_login_slack_auth(request, backend):
def overridden_login_social_auth(request: Request, backend: str) -> Response:
# We can't just redirect frontend here because we need to make a API call and pass tokens to this view from JS.
# So frontend can't follow our redirect.
# So wrapping and returning URL to redirect as a string.
if settings.SLACK_INTEGRATION_MAINTENANCE_ENABLED:
if "slack" in backend and settings.SLACK_INTEGRATION_MAINTENANCE_ENABLED:
return Response(
"Grafana OnCall is temporary unable to connect your slack account or install OnCall to your slack workspace",
status=400,
)
url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url
url_to_redirect_to = do_auth(request.backend, redirect_name=REDIRECT_FIELD_NAME).url
return Response(url_to_redirect_to, 200)
@api_view(["GET"])
@authentication_classes([SlackTokenAuthentication])
@authentication_classes([GoogleTokenAuthentication, SlackTokenAuthentication])
@never_cache
@csrf_exempt
@psa("social:complete")
def overridden_complete_slack_auth(request, backend, *args, **kwargs):
def overridden_complete_social_auth(request: Request, backend: str, *args, **kwargs) -> Response:
"""Authentication complete view"""
# InstallSlackOAuth2V2 backend
redirect_to = "/a/grafana-oncall-app/chat-ops"
if isinstance(request.backend, LoginSlackOAuth2V2):
if isinstance(request.backend, (LoginSlackOAuth2V2, GoogleOAuth2)):
# if this was a user login/linking account, redirect to profile
redirect_to = "/a/grafana-oncall-app/users/me"
else:
# InstallSlackOAuth2V2 backend
redirect_to = "/a/grafana-oncall-app/chat-ops"
kwargs.update(
user=request.user,
@ -71,3 +74,13 @@ def overridden_complete_slack_auth(request, backend, *args, **kwargs):
# We build the frontend url using org url since multiple stacks could be connected to one backend.
return_to = urljoin(request.user.organization.grafana_url, redirect_to)
return HttpResponseRedirect(return_to)
@api_view(["GET"])
@authentication_classes([PluginAuthentication])
@never_cache
@psa("social:disconnect")
def overridden_disconnect_social_auth(request: Request, backend: str) -> Response:
if backend == "google-oauth2":
do_disconnect(request.backend, request.user)
return Response("ok", 200)

View file

@ -24,6 +24,7 @@ class Feature(enum.StrEnum):
# On OnCall side it do nothing, just indicating if OnCall API is ready to that integration.
GRAFANA_ALERTING_V2 = "grafana_alerting_v2"
LABELS = "labels"
GOOGLE_OAUTH2 = "google_oauth2"
class FeaturesAPIView(APIView):
@ -64,4 +65,7 @@ class FeaturesAPIView(APIView):
if is_labels_feature_enabled(self.request.auth.organization):
enabled_features.append(Feature.LABELS)
if settings.FEATURE_GOOGLE_OAUTH2_ENABLED:
enabled_features.append(Feature.GOOGLE_OAUTH2)
return enabled_features

View file

@ -18,7 +18,7 @@ from apps.auth_token.auth import PluginAuthentication
from apps.mobile_app.auth import MobileAppAuthTokenAuthentication
from apps.schedules import exceptions
from apps.schedules.models import ShiftSwapRequest
from apps.schedules.tasks.shift_swaps import create_shift_swap_request_message, update_shift_swap_request_message
from apps.schedules.tasks.shift_swaps import update_shift_swap_request_message
from apps.user_management.models import User
from common.api_helpers.exceptions import BadRequest
from common.api_helpers.mixins import PublicPrimaryKeyMixin
@ -33,13 +33,6 @@ class BaseShiftSwapViewSet(ModelViewSet):
serializer_class = ShiftSwapRequestSerializer
pagination_class = FiftyPageSizePaginator
def _do_create(self, beneficiary: User, serializer: BaseSerializer[ShiftSwapRequest]) -> None:
shift_swap_request = serializer.save(beneficiary=beneficiary)
write_resource_insight_log(instance=shift_swap_request, author=self.request.user, event=EntityEvent.CREATED)
create_shift_swap_request_message.apply_async((shift_swap_request.pk,))
def _do_take(self, benefactor: User) -> dict:
shift_swap = self.get_object()
prev_state = shift_swap.insight_logs_serialized
@ -83,7 +76,7 @@ class BaseShiftSwapViewSet(ModelViewSet):
def perform_create(self, serializer: BaseSerializer[ShiftSwapRequest]) -> None:
# default to create swap request with logged in user as beneficiary
self._do_create(self.request.user, serializer=serializer)
serializer.save(beneficiary=self.request.user)
def perform_update(self, serializer: BaseSerializer[ShiftSwapRequest]) -> None:
prev_state = serializer.instance.insight_logs_serialized

View file

@ -1,6 +1,6 @@
import json
import logging
from typing import Tuple
import typing
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
@ -16,15 +16,24 @@ from apps.user_management.models import User
from apps.user_management.models.organization import Organization
from settings.base import SELF_HOSTED_SETTINGS
from .constants import SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME
from .constants import GOOGLE_OAUTH2_AUTH_TOKEN_NAME, SCHEDULE_EXPORT_TOKEN_NAME, SLACK_AUTH_TOKEN_NAME
from .exceptions import InvalidToken
from .grafana.grafana_auth_token import get_service_account_token_permissions
from .models import ApiAuthToken, PluginAuthToken, ScheduleExportAuthToken, SlackAuthToken, UserScheduleExportAuthToken
from .models.integration_backsync_auth_token import IntegrationBacksyncAuthToken
from .models import (
ApiAuthToken,
GoogleOAuth2Token,
IntegrationBacksyncAuthToken,
PluginAuthToken,
ScheduleExportAuthToken,
SlackAuthToken,
UserScheduleExportAuthToken,
)
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
T = typing.TypeVar("T")
class ServerUser(AnonymousUser):
@property
@ -77,7 +86,7 @@ class BasePluginAuthentication(BaseAuthentication):
# Check parent's method comments
return "Bearer"
def authenticate(self, request: Request) -> Tuple[User, PluginAuthToken]:
def authenticate(self, request: Request) -> typing.Tuple[User, PluginAuthToken]:
token_string = get_authorization_header(request).decode()
if not token_string:
@ -85,7 +94,7 @@ class BasePluginAuthentication(BaseAuthentication):
return self.authenticate_credentials(token_string, request)
def authenticate_credentials(self, token_string: str, request: Request) -> Tuple[User, PluginAuthToken]:
def authenticate_credentials(self, token_string: str, request: Request) -> typing.Tuple[User, PluginAuthToken]:
context_string = request.headers.get("X-Instance-Context")
if not context_string:
raise exceptions.AuthenticationFailed("No instance context provided.")
@ -184,7 +193,7 @@ class GrafanaIncidentStaticKeyAuth(BaseAuthentication):
# Check parent's method comments
return "Bearer"
def authenticate(self, request: Request) -> Tuple[GrafanaIncidentUser, None]:
def authenticate(self, request: Request) -> typing.Tuple[GrafanaIncidentUser, None]:
token_string = get_authorization_header(request).decode()
if (
@ -198,7 +207,7 @@ class GrafanaIncidentStaticKeyAuth(BaseAuthentication):
return self.authenticate_credentials(token_string, request)
def authenticate_credentials(self, token_string: str, request: Request) -> Tuple[GrafanaIncidentUser, None]:
def authenticate_credentials(self, token_string: str, request: Request) -> typing.Tuple[GrafanaIncidentUser, None]:
try:
user = GrafanaIncidentUser()
except InvalidToken:
@ -207,29 +216,41 @@ class GrafanaIncidentStaticKeyAuth(BaseAuthentication):
return user, None
class SlackTokenAuthentication(BaseAuthentication):
class _SocialAuthTokenAuthentication(BaseAuthentication, typing.Generic[T]):
def authenticate(self, request) -> typing.Optional[typing.Tuple[User, T]]:
"""
If you don't return `None`, the authenticate will raise an `APIException`, so the next authentication class
will not be called.
https://stackoverflow.com/a/61623607/3902555
This is useful for the social_auth views where we want to use multiple authentication classes
for the same view.
"""
auth = request.query_params.get(self.token_query_param_name)
if not auth:
return None
try:
auth_token = self.model.validate_token_string(auth)
return auth_token.user, auth_token
except InvalidToken:
return None
class SlackTokenAuthentication(_SocialAuthTokenAuthentication[SlackAuthToken]):
token_query_param_name = SLACK_AUTH_TOKEN_NAME
model = SlackAuthToken
def authenticate(self, request) -> Tuple[User, SlackAuthToken]:
auth = request.query_params.get(SLACK_AUTH_TOKEN_NAME)
if not auth:
raise exceptions.AuthenticationFailed("Invalid token.")
user, auth_token = self.authenticate_credentials(auth)
return user, auth_token
def authenticate_credentials(self, token_string: str) -> Tuple[User, SlackAuthToken]:
try:
auth_token = self.model.validate_token_string(token_string)
except InvalidToken:
raise exceptions.AuthenticationFailed("Invalid token.")
return auth_token.user, auth_token
class GoogleTokenAuthentication(_SocialAuthTokenAuthentication[GoogleOAuth2Token]):
token_query_param_name = GOOGLE_OAUTH2_AUTH_TOKEN_NAME
model = GoogleOAuth2Token
class ScheduleExportAuthentication(BaseAuthentication):
model = ScheduleExportAuthToken
def authenticate(self, request) -> Tuple[User, ScheduleExportAuthToken]:
def authenticate(self, request) -> typing.Tuple[User, ScheduleExportAuthToken]:
auth = request.query_params.get(SCHEDULE_EXPORT_TOKEN_NAME)
public_primary_key = request.parser_context.get("kwargs", {}).get("pk")
if not auth:
@ -240,7 +261,7 @@ class ScheduleExportAuthentication(BaseAuthentication):
def authenticate_credentials(
self, token_string: str, public_primary_key: str
) -> Tuple[User, ScheduleExportAuthToken]:
) -> typing.Tuple[User, ScheduleExportAuthToken]:
try:
auth_token = self.model.validate_token_string(token_string)
except InvalidToken:
@ -263,7 +284,7 @@ class ScheduleExportAuthentication(BaseAuthentication):
class UserScheduleExportAuthentication(BaseAuthentication):
model = UserScheduleExportAuthToken
def authenticate(self, request) -> Tuple[User, UserScheduleExportAuthToken]:
def authenticate(self, request) -> typing.Tuple[User, UserScheduleExportAuthToken]:
auth = request.query_params.get(SCHEDULE_EXPORT_TOKEN_NAME)
public_primary_key = request.parser_context.get("kwargs", {}).get("pk")
@ -275,7 +296,7 @@ class UserScheduleExportAuthentication(BaseAuthentication):
def authenticate_credentials(
self, token_string: str, public_primary_key: str
) -> Tuple[User, UserScheduleExportAuthToken]:
) -> typing.Tuple[User, UserScheduleExportAuthToken]:
try:
auth_token = self.model.validate_token_string(token_string)
except InvalidToken:
@ -363,7 +384,7 @@ class GrafanaServiceAccountAuthentication(BaseAuthentication):
class IntegrationBacksyncAuthentication(BaseAuthentication):
model = IntegrationBacksyncAuthToken
def authenticate(self, request) -> Tuple[ServerUser, IntegrationBacksyncAuthToken]:
def authenticate(self, request) -> typing.Tuple[ServerUser, IntegrationBacksyncAuthToken]:
token = get_authorization_header(request).decode("utf-8")
if not token:
@ -371,7 +392,7 @@ class IntegrationBacksyncAuthentication(BaseAuthentication):
return self.authenticate_credentials(token)
def authenticate_credentials(self, token_string: str) -> Tuple[ServerUser, IntegrationBacksyncAuthToken]:
def authenticate_credentials(self, token_string: str) -> typing.Tuple[ServerUser, IntegrationBacksyncAuthToken]:
try:
auth_token = self.model.validate_token_string(token_string)
except InvalidToken:

View file

@ -5,6 +5,12 @@ DIGEST_LENGTH = 128
MAX_PUBLIC_API_TOKENS_PER_USER = 5
SLACK_AUTH_TOKEN_NAME = "slack_login_token"
GOOGLE_OAUTH2_AUTH_TOKEN_NAME = "state"
"""
We must use the `state` query param, otherwise Google returns a 400 error.
https://developers.google.com/identity/protocols/oauth2/web-server#:~:text=Specifies%20any%20string%20value%20that%20your%20application%20uses%20to%20maintain%20state%20between%20your%20authorization%20request%20and%20the%20authorization%20server%27s%20response
"""
SCHEDULE_EXPORT_TOKEN_NAME = "token"
SCHEDULE_EXPORT_TOKEN_CHARACTER_LENGTH = 32

View file

@ -0,0 +1,32 @@
# Generated by Django 4.2.10 on 2024-03-19 10:27
import apps.auth_token.models.google_oauth2_token
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('user_management', '0020_organization_is_grafana_labels_enabled'),
('auth_token', '0005_integrationauthtoken'),
]
operations = [
migrations.CreateModel(
name='GoogleOAuth2Token',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_key', models.CharField(db_index=True, max_length=8)),
('digest', models.CharField(max_length=128)),
('created_at', models.DateTimeField(auto_now_add=True)),
('revoked_at', models.DateTimeField(null=True)),
('expire_date', models.DateTimeField(default=apps.auth_token.models.google_oauth2_token.get_expire_date)),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='google_oauth2_auth_token_set', to='user_management.organization')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='google_oauth2_auth_token_set', to='user_management.user')),
],
options={
'abstract': False,
},
),
]

View file

@ -1,5 +1,6 @@
from .api_auth_token import ApiAuthToken # noqa: F401
from .base_auth_token import BaseAuthToken # noqa: F401
from .google_oauth2_token import GoogleOAuth2Token # noqa: F401
from .integration_backsync_auth_token import IntegrationBacksyncAuthToken # noqa: F401
from .plugin_auth_token import PluginAuthToken # noqa: F401
from .schedule_export_auth_token import ScheduleExportAuthToken # noqa: F401

View file

@ -0,0 +1,56 @@
from typing import Tuple
from django.db import models
from django.utils import timezone
from apps.auth_token import constants, crypto
from apps.auth_token.models import BaseAuthToken
from apps.user_management.models import Organization, User
from settings.base import AUTH_TOKEN_TIMEOUT_SECONDS
def get_expire_date():
return timezone.now() + timezone.timedelta(seconds=AUTH_TOKEN_TIMEOUT_SECONDS)
class GoogleOAuth2TokenQueryset(models.QuerySet):
def filter(self, *args, **kwargs):
now = timezone.now()
return super().filter(*args, **kwargs, revoked_at=None, expire_date__gte=now)
def delete(self):
self.update(revoked_at=timezone.now())
class GoogleOAuth2Token(BaseAuthToken):
"""
Not to be confused with `apps.google.models.GoogleOAuth2User` which is a model for storing user/token data that is
received from Google OAuth2 when the user completes the OAuth2 flow.
This model is primarly used for storing a token during the OAuth2 redirect flow to allow us to identify the user
after they've been redirected back to us.
"""
objects = GoogleOAuth2TokenQueryset.as_manager()
user = models.ForeignKey(
"user_management.User",
related_name="google_oauth2_auth_token_set",
on_delete=models.CASCADE,
)
organization = models.ForeignKey(
"user_management.Organization", related_name="google_oauth2_auth_token_set", on_delete=models.CASCADE
)
expire_date = models.DateTimeField(default=get_expire_date)
@classmethod
def create_auth_token(cls, user: User, organization: Organization) -> Tuple["GoogleOAuth2Token", str]:
token_string = crypto.generate_token_string()
digest = crypto.hash_token_string(token_string)
instance = cls.objects.create(
token_key=token_string[: constants.TOKEN_KEY_LENGTH],
digest=digest,
user=user,
organization=organization,
)
return instance, token_string

View file

@ -6,11 +6,11 @@ from django.utils import timezone
from apps.auth_token import constants, crypto
from apps.auth_token.models import BaseAuthToken
from apps.user_management.models import Organization, User
from settings.base import SLACK_AUTH_TOKEN_TIMEOUT_SECONDS
from settings.base import AUTH_TOKEN_TIMEOUT_SECONDS
def get_expire_date():
return timezone.now() + timezone.timedelta(seconds=SLACK_AUTH_TOKEN_TIMEOUT_SECONDS)
return timezone.now() + timezone.timedelta(seconds=AUTH_TOKEN_TIMEOUT_SECONDS)
class SlackAuthTokenQueryset(models.QuerySet):

View file

@ -40,7 +40,7 @@ def test_multi_type_support(value):
LiveSetting.objects.create(name="SOME_NEW_FEATURE_ENABLED", value=value)
setting_value = LiveSetting.get_setting("SOME_NEW_FEATURE_ENABLED")
assert type(setting_value) == type(value)
assert type(setting_value) is type(value)
assert setting_value == value

View file

View file

@ -0,0 +1,84 @@
import datetime
import logging
import typing
from django.conf import settings
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from apps.google import constants, utils
from apps.google.types import GoogleCalendarEvent as GoogleCalendarEventType
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
class GoogleCalendarEvent:
def __init__(self, event: GoogleCalendarEventType):
self.raw_event = event
self._start_time = utils.datetime_strptime(event["start"]["dateTime"])
self._end_time = utils.datetime_strptime(event["end"]["dateTime"])
self.start_time_utc = self._start_time.astimezone(datetime.timezone.utc)
self.end_time_utc = self._end_time.astimezone(datetime.timezone.utc)
class GoogleCalendarAPIClient:
MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH = 250
"""
By default the value is 250 events. The page size can never be larger than 2500 events
"""
CALENDAR_ID = "primary"
"""
for right now we only consider the user's primary calendar. If in the future we
want to allow the user to specify a different calendar, we'd need to [retrieve all their calendars](https://developers.google.com/calendar/v3/reference/calendarList/list)
, display this list to them + perist their choice
See `calendarId` under the "Parameters" section [here](https://developers.google.com/calendar/api/v3/reference/events/list)
"""
def __init__(self, access_token: str, refresh_token: str):
"""
https://developers.google.com/calendar/api/quickstart/python
https://google-auth.readthedocs.io/en/stable/reference/google.oauth2.credentials.html
"""
credentials = Credentials(
token=access_token,
refresh_token=refresh_token,
token_uri="https://www.googleapis.com/oauth2/v3/token",
client_id=settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY,
client_secret=settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET,
)
self.service = build("calendar", "v3", credentials=credentials)
def fetch_out_of_office_events(self) -> typing.List[GoogleCalendarEvent]:
"""
https://developers.google.com/calendar/api/v3/reference/events/list
"""
logger.info(
f"GoogleCalendarAPIClient - Getting the upcoming {self.MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH} "
"out of office events"
)
now = datetime.datetime.now(datetime.UTC)
time_min = utils.datetime_strftime(now)
time_max = utils.datetime_strftime(
now + datetime.timedelta(days=constants.DAYS_IN_FUTURE_TO_CONSIDER_OUT_OF_OFFICE_EVENTS)
)
events_result = (
self.service.events()
.list(
calendarId=self.CALENDAR_ID,
timeMin=time_min,
timeMax=time_max,
maxResults=self.MAX_NUMBER_OF_CALENDAR_EVENTS_TO_FETCH,
singleEvents=True,
orderBy="startTime",
eventTypes="outOfOffice",
)
.execute()
)
return [GoogleCalendarEvent(event) for event in events_result.get("items", [])]

View file

@ -0,0 +1,6 @@
GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
"""
https://stackoverflow.com/a/17159470/3902555
"""
DAYS_IN_FUTURE_TO_CONSIDER_OUT_OF_OFFICE_EVENTS = 30

View file

@ -0,0 +1,29 @@
# Generated by Django 4.2.10 on 2024-03-17 19:54
from django.db import migrations, models
import django.db.models.deletion
import mirage.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
('user_management', '0020_organization_is_grafana_labels_enabled'),
]
operations = [
migrations.CreateModel(
name='GoogleOAuth2User',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('google_user_id', models.CharField(max_length=100)),
('access_token', mirage.fields.EncryptedCharField(max_length=300)),
('refresh_token', mirage.fields.EncryptedCharField(max_length=300)),
('oauth_scope', models.TextField(max_length=30000)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='google_oauth2_user', to='user_management.user')),
],
),
]

View file

@ -0,0 +1 @@
from .google_oauth2_user import GoogleOAuth2User # noqa: F401

View file

@ -0,0 +1,21 @@
import typing
from django.db import models
from mirage import fields as mirage_fields
if typing.TYPE_CHECKING:
from apps.user_management.models import User
class GoogleOAuth2User(models.Model):
user: "User"
id = models.AutoField(primary_key=True)
user = models.OneToOneField(
to="user_management.User", null=False, blank=False, related_name="google_oauth2_user", on_delete=models.CASCADE
)
google_user_id = models.CharField(max_length=100)
access_token = mirage_fields.EncryptedCharField(max_length=300)
refresh_token = mirage_fields.EncryptedCharField(max_length=300)
oauth_scope = models.TextField(max_length=30000)
created_at = models.DateTimeField(auto_now_add=True)

View file

@ -0,0 +1,87 @@
import logging
from celery.utils.log import get_task_logger
from apps.google.client import GoogleCalendarAPIClient
from apps.google.models import GoogleOAuth2User
from apps.schedules.models import OnCallSchedule, ShiftSwapRequest
from common.custom_celery_tasks import shared_dedicated_queue_retry_task
logger = get_task_logger(__name__)
logger.setLevel(logging.DEBUG)
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True)
def sync_out_of_office_calendar_events_for_user(google_oauth2_user_pk: int) -> None:
google_oauth2_user = GoogleOAuth2User.objects.get(pk=google_oauth2_user_pk)
google_api_client = GoogleCalendarAPIClient(google_oauth2_user.access_token, google_oauth2_user.refresh_token)
user = google_oauth2_user.user
user_id = user.pk
logger.info(f"Syncing out of office Google Calendar events for user {user_id}")
users_schedules = OnCallSchedule.objects.related_to_user(user)
user_google_calendar_settings = user.google_calendar_settings
oncall_schedules_to_consider_for_shift_swaps = user_google_calendar_settings[
"oncall_schedules_to_consider_for_shift_swaps"
]
if oncall_schedules_to_consider_for_shift_swaps:
users_schedules = users_schedules.filter(public_primary_key__in=oncall_schedules_to_consider_for_shift_swaps)
for out_of_office_event in google_api_client.fetch_out_of_office_events():
event_id = out_of_office_event.raw_event["id"]
start_time_utc = out_of_office_event.start_time_utc
end_time_utc = out_of_office_event.end_time_utc
logger.info(
f"Processing out of office event {event_id} starting at {start_time_utc} and ending at "
f"{end_time_utc} for user {user_id}"
)
for schedule in users_schedules:
_, _, upcoming_shifts = schedule.shifts_for_user(
user,
start_time_utc,
datetime_end=end_time_utc,
)
if upcoming_shifts:
logger.info(
f"Found {len(upcoming_shifts)} upcoming shift(s) for user {user_id} "
f"during the out of office event {event_id}"
)
shift_swap_request_exists = ShiftSwapRequest.objects.filter(
beneficiary=user,
schedule=schedule,
swap_start=start_time_utc,
swap_end=end_time_utc,
).exists()
if not shift_swap_request_exists:
logger.info(
f"Creating shift swap request for user {user_id} schedule {schedule.pk} "
f"due to the out of office event {event_id}"
)
ssr = ShiftSwapRequest.objects.create(
beneficiary=user,
schedule=schedule,
swap_start=start_time_utc,
swap_end=end_time_utc,
description=f"{user.name or user.email} will be out of office during this time according to Google Calendar",
)
logger.info(f"Created shift swap request {ssr.pk}")
else:
logger.info(f"Shift swap request already exists for user {user_id} schedule {schedule.pk}")
else:
logger.info(f"No upcoming shifts found for user {user_id} during the out of office event {event_id}")
@shared_dedicated_queue_retry_task(autoretry_for=(Exception,), retry_backoff=True)
def sync_out_of_office_calendar_events_for_all_users() -> None:
for google_oauth2_user in GoogleOAuth2User.objects.all():
sync_out_of_office_calendar_events_for_user.apply_async(args=(google_oauth2_user.pk,))

View file

View file

@ -0,0 +1,14 @@
import factory
from apps.google.models import GoogleOAuth2User
from common.utils import UniqueFaker
class GoogleOAuth2UserFactory(factory.DjangoModelFactory):
google_user_id = UniqueFaker("pyint")
access_token = factory.Faker("password")
refresh_token = factory.Faker("password")
oauth_scope = factory.Faker("word")
class Meta:
model = GoogleOAuth2User

View file

@ -0,0 +1,291 @@
import datetime
from unittest.mock import patch
import pytest
from django.utils import timezone
from apps.google import constants, tasks
from apps.schedules.models import CustomOnCallShift, OnCallScheduleWeb, ShiftSwapRequest
def _create_mock_google_calendar_event(start_time: datetime.datetime, end_time: datetime.datetime):
return {
"colorId": "4",
"created": "2024-03-22T23:06:39.000Z",
"creator": {
"email": "joey.orlando@grafana.com",
"self": True,
},
"end": {
"dateTime": end_time.strftime(constants.GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT),
"timeZone": "America/New_York",
},
"etag": "3422297608598000",
"eventType": "outOfOffice",
"extendedProperties": {
"private": {
"reclaim.event.category": "VACATION",
"reclaim.priority.index": "3",
"reclaim.project.id": "NULL",
"reclaim.touched": "true",
},
},
"htmlLink": "https://www.google.com/calendar/event?eid=NDlyZGVmNHU2aTVkaDR1aWFycGZqYWoya3Qgam9leS5vcmxhbmRvQGdyYWZhbmEuY29t",
"iCalUID": "49rdef4u6i5dh4uiarpfjaj2kt@google.com",
"id": "49rdef4u6i5dh4uiarpfjaj2kt",
"kind": "calendar#event",
"organizer": {
"email": "joey.orlando@grafana.com",
"self": True,
},
"outOfOfficeProperties": {
"autoDeclineMode": "declineNone",
},
"reminders": {
"useDefault": False,
},
"sequence": 0,
"start": {
"dateTime": start_time.strftime(constants.GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT),
"timeZone": "America/New_York",
},
"status": "confirmed",
"summary": "Out of office",
"updated": "2024-03-22T23:06:44.299Z",
"visibility": "public",
}
def _create_event_start_and_end_times(start_days_in_future=5, end_time_minutes_past_start=50):
start_time = (
datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=start_days_in_future)
).replace(second=0, microsecond=0)
end_time = start_time + datetime.timedelta(minutes=end_time_minutes_past_start)
return start_time, end_time
@pytest.fixture
def make_schedule_with_on_call_shift(make_schedule, make_on_call_shift):
def _make_schedule_with_on_call_shift(out_of_office_events, organization, user):
schedule = make_schedule(
organization,
schedule_class=OnCallScheduleWeb,
channel="channel",
prev_ical_file_overrides=None,
cached_ical_file_overrides=None,
)
dt_format = constants.GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT
if out_of_office_events:
on_call_shift_start = datetime.datetime.strptime(
out_of_office_events[0]["start"]["dateTime"], dt_format
) - datetime.timedelta(days=60)
else:
on_call_shift_start = timezone.now() - datetime.timedelta(days=60)
on_call_shift = make_on_call_shift(
organization=organization,
shift_type=CustomOnCallShift.TYPE_ROLLING_USERS_EVENT,
start=on_call_shift_start,
rotation_start=on_call_shift_start,
duration=datetime.timedelta(days=365),
priority_level=1,
frequency=CustomOnCallShift.FREQUENCY_DAILY,
schedule=schedule,
)
on_call_shift.add_rolling_users([[user]])
schedule.refresh_ical_file()
schedule.refresh_ical_final_schedule()
return schedule
return _make_schedule_with_on_call_shift
@pytest.fixture
def test_setup(
make_organization,
make_user_for_organization,
make_google_oauth2_user_for_user,
make_schedule_with_on_call_shift,
):
def _test_setup(out_of_office_events):
organization = make_organization()
user_name = "Bob Smith"
user = make_user_for_organization(
organization,
# normally this 👇 is done via User.finish_google_oauth2_connection_flow.. but since we're creating
# the user via a fixture we need to manually add this
google_calendar_settings={
"oncall_schedules_to_consider_for_shift_swaps": [],
},
name=user_name,
)
google_oauth2_user = make_google_oauth2_user_for_user(user)
schedule = make_schedule_with_on_call_shift(out_of_office_events, organization, user)
return google_oauth2_user, schedule
return _test_setup
@patch("apps.google.client.build")
@pytest.mark.django_db
def test_sync_out_of_office_calendar_events_for_user_no_ooo_events(mock_google_api_client_build, test_setup):
out_of_office_events = []
mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.return_value = {
"items": out_of_office_events,
}
google_oauth2_user, schedule = test_setup(out_of_office_events)
user = google_oauth2_user.user
tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk)
assert ShiftSwapRequest.objects.filter(beneficiary=user, schedule=schedule).count() == 0
@patch("apps.google.client.build")
@pytest.mark.django_db
def test_sync_out_of_office_calendar_events_for_user_single_ooo_event(mock_google_api_client_build, test_setup):
start_time, end_time = _create_event_start_and_end_times()
out_of_office_events = [
_create_mock_google_calendar_event(start_time, end_time),
]
mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.return_value = {
"items": out_of_office_events,
}
google_oauth2_user, schedule = test_setup(out_of_office_events)
user = google_oauth2_user.user
tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk)
ssrs = ShiftSwapRequest.objects.filter(beneficiary=user, schedule=schedule)
ssr = ssrs.first()
assert ssrs.count() == 1
assert ssr.swap_start == start_time
assert ssr.swap_end == end_time
assert ssr.description == f"{user.name} will be out of office during this time according to Google Calendar"
@patch("apps.google.client.build")
@pytest.mark.django_db
def test_sync_out_of_office_calendar_events_for_user_multiple_ooo_events(mock_google_api_client_build, test_setup):
# partial day out of office event
event1_start_time, event1_end_time = _create_event_start_and_end_times()
# all day out of office event
event2_start_time, event2_end_time = _create_event_start_and_end_times(6, 24 * 60)
out_of_office_events = [
_create_mock_google_calendar_event(event1_start_time, event1_end_time),
_create_mock_google_calendar_event(event2_start_time, event2_end_time),
]
mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.return_value = {
"items": out_of_office_events,
}
google_oauth2_user, schedule = test_setup(out_of_office_events)
user = google_oauth2_user.user
tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk)
assert ShiftSwapRequest.objects.filter(beneficiary=user, schedule=schedule).count() == 2
@patch("apps.google.client.build")
@pytest.mark.django_db
def test_sync_out_of_office_calendar_events_for_user_oncall_schedules_to_consider_for_shift_swaps_setting(
mock_google_api_client_build,
test_setup,
make_schedule_with_on_call_shift,
):
start_time, end_time = _create_event_start_and_end_times()
out_of_office_events = [
_create_mock_google_calendar_event(start_time, end_time),
]
mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.return_value = {
"items": out_of_office_events,
}
google_oauth2_user, schedule1 = test_setup(out_of_office_events)
user = google_oauth2_user.user
make_schedule_with_on_call_shift(out_of_office_events, schedule1.organization, user)
user.google_calendar_settings = {
"oncall_schedules_to_consider_for_shift_swaps": [schedule1.public_primary_key],
}
user.save()
tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk)
assert ShiftSwapRequest.objects.filter(beneficiary=user).count() == 1
ssr = ShiftSwapRequest.objects.first()
assert ssr.schedule == schedule1
@patch("apps.google.tasks.OnCallSchedule.shifts_for_user", return_value=([], [], []))
@patch("apps.google.client.build")
@pytest.mark.django_db
def test_sync_out_of_office_calendar_events_for_user_no_upcoming_shifts(
mock_google_api_client_build,
_mock_schedule_shifts_for_user,
test_setup,
):
start_time, end_time = _create_event_start_and_end_times()
out_of_office_events = [
_create_mock_google_calendar_event(start_time, end_time),
]
mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.return_value = {
"items": out_of_office_events,
}
google_oauth2_user, _ = test_setup(out_of_office_events)
user = google_oauth2_user.user
tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk)
assert ShiftSwapRequest.objects.filter(beneficiary=user).count() == 0
@patch("apps.google.client.build")
@pytest.mark.django_db
def test_sync_out_of_office_calendar_events_for_user_preexisting_shift_swap_request(
mock_google_api_client_build,
test_setup,
make_shift_swap_request,
):
start_time, end_time = _create_event_start_and_end_times()
out_of_office_events = [
_create_mock_google_calendar_event(start_time, end_time),
]
mock_google_api_client_build.return_value.events.return_value.list.return_value.execute.return_value = {
"items": out_of_office_events,
}
google_oauth2_user, schedule = test_setup(out_of_office_events)
user = google_oauth2_user.user
make_shift_swap_request(
schedule,
user,
swap_start=start_time,
swap_end=end_time,
)
tasks.sync_out_of_office_calendar_events_for_user(google_oauth2_user.pk)
# should be 1 because we just created a shift swap request above via the fixture
assert ShiftSwapRequest.objects.filter(beneficiary=user, schedule=schedule).count() == 1

View file

@ -0,0 +1,52 @@
import typing
class GoogleCalendarEventDate(typing.TypedDict):
# NOTE: in reality I haven't seen this field returned, even despite creating
# an out of office event with the "All day" checkbox checked. Instead it looks
# like it just returns the start.dateTime and end.dateTime as midnight of the
# respective days
# date: typing.NotRequired[str]
# """
# The date, in the format "yyyy-mm-dd", if this is an all-day event.
# """
dateTime: typing.NotRequired[str]
"""
The time, as a combined date-time value (formatted according to RFC3339).
A time zone offset is required unless a time zone is explicitly specified in timeZone.
"""
timeZone: typing.NotRequired[str]
"""
The time zone in which the time is specified. (Formatted as an IANA Time Zone Database name, e.g. "Europe/Zurich".)
For recurring events this field is required and specifies the time zone in which the recurrence is expanded.
For single events this field is optional and indicates a custom time zone for the event start/end.
"""
class GoogleCalendarEvent(typing.TypedDict):
"""
https://developers.google.com/calendar/api/v3/reference/events#resource
"""
id: str
"""
Opaque identifier of the event
"""
start: GoogleCalendarEventDate
"""
The (inclusive) start time of the event. For a recurring event, this is the start time of the first instance.
"""
end: GoogleCalendarEventDate
"""
The (exclusive) end time of the event. For a recurring event, this is the end time of the first instance.
"""
summary: str
"""
Title of the event
"""

View file

@ -0,0 +1,11 @@
import datetime
from apps.google import constants
def datetime_strftime(dt: datetime.datetime) -> str:
return dt.strftime(constants.GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT)
def datetime_strptime(dt: str) -> datetime.datetime:
return datetime.datetime.strptime(dt, constants.GOOGLE_CALENDAR_EVENT_DATETIME_FORMAT)

View file

@ -78,7 +78,7 @@ def test_export_calendar(make_organization_and_user_with_token, make_user_for_or
cal = Calendar.from_ical(response.data)
assert type(cal) == Calendar
assert type(cal) is Calendar
# check there are events
assert len(cal.subcomponents) > 0
for component in cal.walk():
@ -112,7 +112,7 @@ def test_export_user_calendar(make_organization_and_user_with_token, make_schedu
cal = Calendar.from_ical(response.data)
assert type(cal) == Calendar
assert type(cal) is Calendar
assert cal.get("x-wr-calname") == "On-Call Schedule for {0}".format(user.username)
assert cal.get("x-wr-timezone") == "UTC"
assert cal.get("calscale") == "GREGORIAN"

View file

@ -112,8 +112,8 @@ def test_list_filters(
assert_expected(response, (swap4,))
@patch("apps.api.views.shift_swap.write_resource_insight_log")
@patch("apps.api.views.shift_swap.create_shift_swap_request_message")
@patch("apps.schedules.models.shift_swap_request.write_resource_insight_log")
@patch("apps.schedules.tasks.shift_swaps.create_shift_swap_request_message")
@pytest.mark.django_db
def test_create(
mock_create_shift_swap_request_message,
@ -122,7 +122,7 @@ def test_create(
make_user_for_organization,
make_schedule,
):
organization, user, token = make_organization_and_user_with_token()
organization, _, token = make_organization_and_user_with_token()
another_user = make_user_for_organization(organization)
schedule = make_schedule(organization, schedule_class=OnCallScheduleWeb)
today = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0)
@ -145,7 +145,9 @@ def test_create(
assert_swap_response(response, data)
ssr = ShiftSwapRequest.objects.get(public_primary_key=response.json()["id"])
mock_write_resource_insight_log.assert_called_once_with(instance=ssr, author=user, event=EntityEvent.CREATED)
mock_write_resource_insight_log.assert_called_once_with(
instance=ssr, author=another_user, event=EntityEvent.CREATED
)
mock_create_shift_swap_request_message.apply_async.assert_called_once_with((ssr.pk,))

View file

@ -85,8 +85,7 @@ class ShiftSwapViewSet(RateLimitHeadersMixin, BaseShiftSwapViewSet):
return user
def perform_create(self, serializer: BaseSerializer[ShiftSwapRequest]) -> None:
beneficiary = self._get_user("beneficiary")
self._do_create(beneficiary=beneficiary, serializer=serializer)
serializer.save(beneficiary=self._get_user("beneficiary"))
@action(methods=["post"], detail=True)
def take(self, request: AuthenticatedRequest, pk: str) -> Response:

View file

@ -187,7 +187,7 @@ def list_of_oncall_shifts_from_ical(
pytz_tz = pytz.timezone("UTC")
return (
datetime.datetime.combine(e["start"], datetime.datetime.min.time(), tzinfo=pytz_tz)
if type(e["start"]) == datetime.date
if type(e["start"]) is datetime.date
else e["start"]
)
@ -234,7 +234,7 @@ def get_shifts_dict(
)
# Define on-call shift out of ical event that has the actual user
if len(users) > 0 or with_empty_shifts:
if type(event[ICAL_DATETIME_START].dt) == datetime.date:
if type(event[ICAL_DATETIME_START].dt) is datetime.date:
start = event[ICAL_DATETIME_START].dt
end = event[ICAL_DATETIME_END].dt
result_date.append(
@ -642,7 +642,7 @@ def is_icals_equal(first, second):
def ical_date_to_datetime(date, tz, start):
datetime_to_combine = datetime.time.min
all_day = False
if type(date) == datetime.date:
if type(date) is datetime.date:
all_day = True
calendar_timezone_offset = datetime.datetime.now().astimezone(tz).utcoffset()
date = datetime.datetime.combine(date, datetime_to_combine).astimezone(tz) - calendar_timezone_offset
@ -795,7 +795,7 @@ def start_end_with_respect_to_all_day(event: IcalEvent, calendar_tz):
def event_start_end_all_day_with_respect_to_type(event: IcalEvent, calendar_tz):
all_day = False
if type(event[ICAL_DATETIME_START].dt) == datetime.date:
if type(event[ICAL_DATETIME_START].dt) is datetime.date:
start, end = start_end_with_respect_to_all_day(event, calendar_tz)
all_day = True
else:

View file

@ -388,7 +388,7 @@ class OnCallSchedule(PolymorphicModel):
events: ScheduleEvents = []
for shift in shifts:
start = shift["start"]
all_day = type(start) == datetime.date
all_day = type(start) is datetime.date
# fix confusing end date for all-day event
end = shift["end"] - datetime.timedelta(days=1) if all_day else shift["end"]
if all_day and all_day_datetime:
@ -513,7 +513,7 @@ class OnCallSchedule(PolymorphicModel):
# check if event was ended or cancelled, update ical
dtend = component.get(ICAL_DATETIME_END)
dtend_datetime = dtend.dt if dtend else None
if dtend_datetime and type(dtend_datetime) == datetime.date:
if dtend_datetime and type(dtend_datetime) is datetime.date:
# shift or overrides coming from ical calendars can be all day events, change to datetime
dtend_datetime = datetime.datetime.combine(
dtend.dt, datetime.datetime.min.time(), tzinfo=pytz.UTC
@ -542,10 +542,23 @@ class OnCallSchedule(PolymorphicModel):
self.save(update_fields=["cached_ical_final_schedule"])
def shifts_for_user(
self, user: User, datetime_start: datetime.datetime, days: int = 7
self,
user: User,
datetime_start: datetime.datetime,
datetime_end: typing.Optional[datetime.datetime] = None,
days: typing.Optional[int] = None,
) -> typing.Tuple[ScheduleEvents, ScheduleEvents, ScheduleEvents]:
"""
NOTE: must specify at least `datetime_end` or `days`
"""
if not datetime_end and not days:
raise ValueError("Must specify at least `datetime_end` or `days`")
now = timezone.now()
datetime_end = datetime_start + datetime.timedelta(days=days)
if days is not None:
datetime_end = datetime_start + datetime.timedelta(days=days)
passed_shifts: ScheduleEvents = []
current_shifts: ScheduleEvents = []
upcoming_shifts: ScheduleEvents = []

View file

@ -5,10 +5,13 @@ from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models import QuerySet
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from apps.schedules import exceptions
from apps.schedules.tasks import refresh_ical_final_schedule
from common.insight_log import EntityEvent, write_resource_insight_log
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
if typing.TYPE_CHECKING:
@ -256,3 +259,14 @@ class ShiftSwapRequest(models.Model):
result["schedule"] = self.schedule.insight_logs_verbal
result["schedule_id"] = self.schedule.public_primary_key
return result
@receiver(post_save, sender=ShiftSwapRequest)
def listen_for_shiftswaprequest_model_save(
sender: ShiftSwapRequest, instance: ShiftSwapRequest, created: bool, *args, **kwargs
) -> None:
from apps.schedules.tasks.shift_swaps import create_shift_swap_request_message
if created:
write_resource_insight_log(instance=instance, author=instance.beneficiary, event=EntityEvent.CREATED)
create_shift_swap_request_message.apply_async((instance.pk,))

View file

@ -2663,7 +2663,7 @@ def test_shifts_for_user(
schedule.refresh_ical_file()
schedule.refresh_ical_final_schedule()
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(admin, now)
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(admin, now, days=7)
assert len(passed_shifts) == 0
assert len(current_shifts) == 1
assert len(upcoming_shifts) == 7
@ -2678,7 +2678,7 @@ def test_shifts_for_user(
users = {u["pk"] for u in shift["users"]}
assert admin.public_primary_key in users
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(other_user, now)
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(other_user, now, days=7)
assert len(passed_shifts) == 0
assert len(current_shifts) == 0
assert len(upcoming_shifts) == 0
@ -2731,7 +2731,7 @@ def test_shifts_for_user_only_two_users_with_shifts(
schedule.refresh_ical_final_schedule()
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(current_user, start_date, days)
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(current_user, start_date, days=days)
assert len(passed_shifts) == 0
assert len(current_shifts) == 0
assert len(upcoming_shifts) == 4
@ -2740,7 +2740,7 @@ def test_shifts_for_user_only_two_users_with_shifts(
assert current_user.public_primary_key in users
assert shift["start"] > now
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(user2, start_date, days)
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(user2, start_date, days=days)
assert len(passed_shifts) > 0
assert len(current_shifts) > 0
assert len(upcoming_shifts) > 0
@ -2774,7 +2774,7 @@ def test_shifts_for_user_no_events(
start_date = today - timezone.timedelta(days=2)
days = 7
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(current_user, start_date, days)
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(current_user, start_date, days=days)
assert len(passed_shifts) == 0
assert len(current_shifts) == 0
assert len(upcoming_shifts) == 0
@ -2795,7 +2795,7 @@ def test_shifts_for_user_without_final_ical(
start_date = today - timezone.timedelta(days=2)
days = 7
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(user, start_date, days)
passed_shifts, current_shifts, upcoming_shifts = schedule.shifts_for_user(user, start_date, days=days)
assert len(passed_shifts) == 0
assert len(current_shifts) == 0
assert len(upcoming_shifts) == 0

View file

@ -22,9 +22,9 @@ class AlertGroupLogSlackRenderer:
# get rendered logs
result = ""
for log_record in all_log_records: # list of AlertGroupLogRecord and UserNotificationPolicyLogRecord logs
if type(log_record) == AlertGroupLogRecord:
if type(log_record) is AlertGroupLogRecord:
result += f"{log_record.rendered_incident_log_line(for_slack=True)}\n"
elif type(log_record) == UserNotificationPolicyLogRecord:
elif type(log_record) is UserNotificationPolicyLogRecord:
result += f"{log_record.rendered_notification_log_line(for_slack=True)}\n"
attachments.append(

View file

@ -1,10 +1,11 @@
from urllib.parse import urljoin
from social_core.backends.google import GoogleOAuth2 as BaseGoogleOAuth2
from social_core.backends.slack import SlackOAuth2
from social_core.utils import handle_http_errors
from apps.auth_token.constants import SLACK_AUTH_TOKEN_NAME
from apps.auth_token.models import SlackAuthToken
from apps.auth_token.models import GoogleOAuth2Token, SlackAuthToken
# Scopes for slack user token.
# It is main purpose - retrieve user data in SlackOAuth2V2 but we are using it in legacy code or weird Slack api cases.
@ -39,11 +40,37 @@ BOT_SCOPE = [
"users:write",
]
# Reference to Slack tokens: https://api.slack.com/authentication/token-types
class GoogleOAuth2(BaseGoogleOAuth2):
REDIRECT_STATE = False
"""
Remove redirect state because we lose session during redirects
"""
STATE_PARAMETER = False
"""
keep `False` to avoid having `BaseGoogleOAuth2` check the `state` query param against a session value
"""
def auth_params(self, state=None):
"""
Override to generate `GoogleOAuth2Token` token to include as `state` query parameter.
https://developers.google.com/identity/protocols/oauth2/web-server#:~:text=Specifies%20any%20string%20value%20that%20your%20application%20uses%20to%20maintain%20state%20between%20your%20authorization%20request%20and%20the%20authorization%20server%27s%20response
"""
params = super().auth_params(state)
_, token_string = GoogleOAuth2Token.create_auth_token(
self.strategy.request.user, self.strategy.request.auth.organization
)
params["state"] = token_string
return params
class SlackOAuth2V2(SlackOAuth2):
"""
Reference to Slack tokens: https://api.slack.com/authentication/token-types
Slack app with granular permissions require using SlackOauth2.0 V2.
SlackOAuth2V2 and its inheritors tune SlackOAuth2 implementation from social core to fit new endpoints
and response shapes.
@ -54,8 +81,10 @@ class SlackOAuth2V2(SlackOAuth2):
ACCESS_TOKEN_URL = "https://slack.com/api/oauth.v2.access"
AUTH_TOKEN_NAME = SLACK_AUTH_TOKEN_NAME
# Remove redirect state because we lose session during redirects
REDIRECT_STATE = False
"""
Remove redirect state because we lose session during redirects
"""
STATE_PARAMETER = False
EXTRA_DATA = [("id", "id"), ("name", "name"), ("real_name", "real_name"), ("team", "team")]

View file

@ -0,0 +1,27 @@
import typing
from django.http import HttpResponse
from rest_framework import status
from social_core.backends.base import BaseAuth
from social_core.exceptions import AuthForbidden
from social_core.strategy import BaseStrategy
from apps.user_management.models import Organization, User
class UserOrganizationKwargsResponse(typing.TypedDict):
user: User
organization: Organization
def set_user_and_organization_from_request(
backend: typing.Type[BaseAuth], strategy: typing.Type[BaseStrategy], *args, **kwargs
) -> UserOrganizationKwargsResponse:
user = strategy.request.user
organization = strategy.request.auth.organization
if user is None or organization is None:
return HttpResponse(str(AuthForbidden(backend)), status=status.HTTP_401_UNAUTHORIZED)
return {
"user": user,
"organization": organization,
}

View file

@ -0,0 +1,27 @@
import logging
import typing
from rest_framework.response import Response
from social_core.backends.base import BaseAuth
from apps.user_management.models import User
logger = logging.getLogger(__name__)
def persist_access_and_refresh_tokens(backend: typing.Type[BaseAuth], response: Response, user: User, *args, **kwargs):
"""
TODO: what happens if `refresh_token` is not present? what are the scenarios that lead to this?
NOTE: I think it's only included when the user initially grants access to our Google OAuth2 app
on subsequent logins, the refresh_token is not included in the response, only access_token.. what to do here?
https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9
"""
user.finish_google_oauth2_connection_flow(response)
def disconnect_user_google_oauth2_settings(backend: typing.Type[BaseAuth], user: User, *args, **kwargs):
# 2nd argument, uid, is not needed for GoogleOauth2 backend
backend.revoke_token(user.google_oauth2_user.access_token, "")
user.finish_google_oauth2_disconnection_flow()

View file

@ -5,7 +5,6 @@ from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.http import HttpResponse
from rest_framework import status
from social_core.exceptions import AuthForbidden
from apps.slack.tasks import populate_slack_channels_for_team, populate_slack_usergroups_for_team
from apps.social_auth.exceptions import InstallMultiRegionSlackException
@ -16,17 +15,6 @@ from common.oncall_gateway import can_link_slack_team_wrapper, link_slack_team_w
logger = logging.getLogger(__name__)
def set_user_and_organization_from_request(backend, strategy, *args, **kwargs):
user = strategy.request.user
organization = strategy.request.auth.organization
if user is None or organization is None:
return HttpResponse(str(AuthForbidden(backend)), status=status.HTTP_401_UNAUTHORIZED)
return {
"user": user,
"organization": organization,
}
def connect_user_to_slack(response, backend, strategy, user, organization, *args, **kwargs):
from apps.slack.models import SlackUserIdentity

View file

@ -0,0 +1,14 @@
import typing
class GoogleOauth2Response(typing.TypedDict):
sub: str
scope: str
access_token: str
refresh_token: typing.Optional[str]
"""
NOTE: I think `refresh_token` is only included when the user initially grants access to our Google OAuth2 app
on subsequent logins, the `refresh_token` is not included in the response, only `access_token`
https://medium.com/starthinker/google-oauth-2-0-access-token-and-refresh-token-explained-cccf2fc0a6d9
"""

View file

@ -2,6 +2,8 @@ import typing
from django.db.models import TextChoices
from apps.user_management.types import AlertGroupTableColumn
class AlertGroupTableDefaultColumnChoices(TextChoices):
STATUS = "status", "Status"
@ -20,18 +22,6 @@ class AlertGroupTableColumnTypeChoices(TextChoices):
LABEL = "label"
class AlertGroupTableColumn(typing.TypedDict):
id: str
name: str
type: str
class AlertGroupTableColumns(typing.TypedDict):
visible: typing.List[AlertGroupTableColumn]
hidden: typing.List[AlertGroupTableColumn]
default: bool
def default_columns() -> typing.List[AlertGroupTableColumn]:
return [
{"name": column.label, "id": column.value, "type": AlertGroupTableColumnTypeChoices.DEFAULT.value}

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.10 on 2024-03-18 22:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('user_management', '0020_organization_is_grafana_labels_enabled'),
]
operations = [
migrations.AddField(
model_name='user',
name='google_calendar_settings',
field=models.JSONField(default=None, null=True),
),
]

View file

@ -11,8 +11,8 @@ from django.utils import timezone
from mirage import fields as mirage_fields
from apps.alerts.models import MaintainableObject
from apps.user_management.constants import AlertGroupTableColumn
from apps.user_management.subscription_strategy import FreePublicBetaSubscriptionStrategy
from apps.user_management.types import AlertGroupTableColumn
from common.insight_log import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.oncall_gateway import (
register_oncall_tenant_wrapper,

View file

@ -7,6 +7,7 @@ from urllib.parse import urljoin
import pytz
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import MinLengthValidator
from django.db import models, transaction
from django.db.models import Q
@ -20,8 +21,9 @@ from apps.api.permissions import (
RBACPermission,
user_is_authorized,
)
from apps.google.models import GoogleOAuth2User
from apps.schedules.tasks import drop_cached_ical_for_custom_events_for_organization
from apps.user_management.constants import AlertGroupTableColumn
from apps.user_management.types import AlertGroupTableColumn, GoogleCalendarSettings
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length
if typing.TYPE_CHECKING:
@ -31,6 +33,7 @@ if typing.TYPE_CHECKING:
from apps.auth_token.models import ApiAuthToken, ScheduleExportAuthToken, UserScheduleExportAuthToken
from apps.base.models import UserNotificationPolicy
from apps.slack.models import SlackUserIdentity
from apps.social_auth.types import GoogleOauth2Response
from apps.user_management.models import Organization, Team
logger = logging.getLogger(__name__)
@ -174,6 +177,7 @@ class User(models.Model):
auth_tokens: "RelatedManager['ApiAuthToken']"
current_team: typing.Optional["Team"]
escalation_policy_notify_queues: "RelatedManager['EscalationPolicy']"
google_oauth2_user: typing.Optional[GoogleOAuth2User]
last_notified_in_escalation_policies: "RelatedManager['EscalationPolicy']"
notification_policies: "RelatedManager['UserNotificationPolicy']"
organization: "Organization"
@ -239,6 +243,8 @@ class User(models.Model):
alert_group_table_selected_columns: list[AlertGroupTableColumn] | None = models.JSONField(default=None, null=True)
google_calendar_settings: GoogleCalendarSettings | None = models.JSONField(default=None, null=True)
def __str__(self):
return f"{self.pk}: {self.username}"
@ -252,6 +258,14 @@ class User(models.Model):
def is_authenticated(self):
return True
@property
def has_google_oauth2_connected(self) -> bool:
try:
# https://stackoverflow.com/a/35005034/3902555
return self.google_oauth2_user is not None
except ObjectDoesNotExist:
return False
@property
def avatar_full_url(self):
return urljoin(self.organization.grafana_url, self.avatar_url)
@ -458,6 +472,28 @@ class User(models.Model):
self.alert_group_table_selected_columns = columns
self.save(update_fields=["alert_group_table_selected_columns"])
def finish_google_oauth2_connection_flow(self, google_oauth2_response: "GoogleOauth2Response") -> None:
_obj, created = GoogleOAuth2User.objects.update_or_create(
user=self,
defaults={
"google_user_id": google_oauth2_response.get("sub"),
"access_token": google_oauth2_response.get("access_token"),
"refresh_token": google_oauth2_response.get("refresh_token"),
"oauth_scope": google_oauth2_response.get("scope"),
},
)
if created:
self.google_calendar_settings = {
"oncall_schedules_to_consider_for_shift_swaps": [],
}
self.save(update_fields=["google_calendar_settings"])
def finish_google_oauth2_disconnection_flow(self) -> None:
GoogleOAuth2User.objects.filter(user=self).delete()
self.google_calendar_settings = None
self.save(update_fields=["google_calendar_settings"])
# TODO: check whether this signal can be moved to save method of the model
@receiver(post_save, sender=User)

View file

@ -518,6 +518,8 @@ def test_sync_organization_lock(make_organization):
@dataclass
class TestSyncGrafanaLabelsPluginParams:
__test__ = False
response: tuple
expected_result: bool
@ -548,6 +550,8 @@ def test_sync_grafana_labels_plugin(make_organization, test_params: TestSyncGraf
@dataclass
class TestSyncGrafanaIncidentParams:
__test__ = False
response: tuple
expected_flag: bool
expected_url: Optional[str]

View file

@ -4,6 +4,7 @@ import pytest
from django.utils import timezone
from apps.api.permissions import LegacyAccessControlRole
from apps.google.models import GoogleOAuth2User
from apps.user_management.models import User
@ -108,3 +109,77 @@ def test_is_telegram_connected(make_organization_and_user, make_telegram_user_co
assert user.is_telegram_connected is False
make_telegram_user_connector(user)
assert user.is_telegram_connected is True
@pytest.mark.django_db
def test_has_google_oauth2_connected(make_organization_and_user, make_google_oauth2_user_for_user):
_, user = make_organization_and_user()
assert user.has_google_oauth2_connected is False
make_google_oauth2_user_for_user(user)
assert user.has_google_oauth2_connected is True
@pytest.mark.django_db
def test_finish_google_oauth2_connection_flow(make_organization_and_user):
oauth_response = {
"access_token": "access",
"refresh_token": "refresh",
"sub": "google_user_id",
"scope": "scope",
}
_, user = make_organization_and_user()
assert GoogleOAuth2User.objects.filter(user=user).exists() is False
assert user.google_calendar_settings is None
user.finish_google_oauth2_connection_flow(oauth_response)
user.refresh_from_db()
google_oauth_user = user.google_oauth2_user
assert google_oauth_user.google_user_id == "google_user_id"
assert google_oauth_user.access_token == "access"
assert google_oauth_user.refresh_token == "refresh"
assert google_oauth_user.oauth_scope == "scope"
assert user.google_calendar_settings["oncall_schedules_to_consider_for_shift_swaps"] == []
oauth_response2 = {
"access_token": "access2",
"refresh_token": "refresh2",
"sub": "google_user_id2",
"scope": "scope2",
}
user.finish_google_oauth2_connection_flow(oauth_response2)
user.refresh_from_db()
google_oauth_user = user.google_oauth2_user
assert google_oauth_user.google_user_id == "google_user_id2"
assert google_oauth_user.access_token == "access2"
assert google_oauth_user.refresh_token == "refresh2"
assert google_oauth_user.oauth_scope == "scope2"
@pytest.mark.django_db
def test_finish_google_oauth2_disconnection_flow(make_organization_and_user):
_, user = make_organization_and_user()
user.finish_google_oauth2_connection_flow(
{
"access_token": "access",
"refresh_token": "refresh",
"sub": "google_user_id",
"scope": "scope",
}
)
user.refresh_from_db()
assert user.google_oauth2_user is not None
assert user.google_calendar_settings is not None
user.finish_google_oauth2_disconnection_flow()
user.refresh_from_db()
assert GoogleOAuth2User.objects.filter(user=user).exists() is False
assert user.google_calendar_settings is None

View file

@ -0,0 +1,20 @@
import typing
class AlertGroupTableColumn(typing.TypedDict):
id: str
name: str
type: str
class AlertGroupTableColumns(typing.TypedDict):
visible: typing.List[AlertGroupTableColumn]
hidden: typing.List[AlertGroupTableColumn]
default: bool
class GoogleCalendarSettings(typing.TypedDict):
oncall_schedules_to_consider_for_shift_swaps: typing.Optional[typing.List[str]]
"""
`public_primary_key` of specific OnCall schedules that should be considered for autogenerated shift swap requests.
"""

View file

@ -40,7 +40,7 @@ def convert_custom_button_to_webhook(apps, schema_editor):
step=EscalationPolicy._DEPRECATED_STEP_TRIGGER_CUSTOM_BUTTON,
custom_button_trigger=cb,
).update(
step=EscalationPolicy._DEPRECATED_STEP_TRIGGER_CUSTOM_BUTTON,
step=EscalationPolicy.STEP_TRIGGER_CUSTOM_WEBHOOK,
custom_webhook=webhook,
)

View file

@ -0,0 +1,30 @@
# Generated by Django 4.2.11 on 2024-04-02 13:41
from django.db import migrations
import django_migration_linter as linter
from apps.alerts.models import EscalationPolicy
def delete_orphaned_custom_button_escalation_policies(apps, schema_editor):
"""
See this conversation for more context behind this
https://raintank-corp.slack.com/archives/C025VMT6SPK/p1711991400490289
"""
EscalationPolicies = apps.get_model("alerts", "EscalationPolicy")
EscalationPolicies.objects.filter(
step=EscalationPolicy._DEPRECATED_STEP_TRIGGER_CUSTOM_BUTTON,
custom_webhook=None,
).delete()
class Migration(migrations.Migration):
dependencies = [
('webhooks', '0015_webhook_is_from_connected_integration'),
]
operations = [
linter.IgnoreMigration(), # removing orphaned escalation policies should be fine, no way to revert this
migrations.RunPython(delete_orphaned_custom_button_escalation_policies)
]

View file

@ -176,7 +176,7 @@ def isoformat_with_tz_suffix(value):
def is_string_with_visible_characters(string):
return type(string) == str and not string.isspace() and not string == ""
return type(string) is str and not string.isspace() and not string == ""
def str_or_backup(string, backup):

View file

@ -45,7 +45,13 @@ from apps.api.permissions import (
LegacyAccessControlRole,
RBACPermission,
)
from apps.auth_token.models import ApiAuthToken, IntegrationBacksyncAuthToken, PluginAuthToken, SlackAuthToken
from apps.auth_token.models import (
ApiAuthToken,
GoogleOAuth2Token,
IntegrationBacksyncAuthToken,
PluginAuthToken,
SlackAuthToken,
)
from apps.base.models.user_notification_policy_log_record import (
UserNotificationPolicyLogRecord,
listen_for_usernotificationpolicylogrecord_model_save,
@ -56,6 +62,7 @@ from apps.base.tests.factories import (
UserNotificationPolicyLogRecordFactory,
)
from apps.email.tests.factories import EmailMessageFactory
from apps.google.tests.factories import GoogleOAuth2UserFactory
from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory
from apps.labels.tests.factories import (
AlertGroupAssociatedLabelFactory,
@ -101,8 +108,6 @@ from apps.webhooks.tests.test_webhook_presets import TEST_WEBHOOK_PRESET_ID, Tes
register(OrganizationFactory)
register(UserFactory)
register(TeamFactory)
register(AlertReceiveChannelFactory)
register(AlertReceiveChannelConnectionFactory)
register(ChannelFilterFactory)
@ -117,29 +122,24 @@ register(AlertGroupLogRecordFactory)
register(InvitationFactory)
register(CustomActionFactory)
register(SlackUserGroupFactory)
register(SlackUserIdentityFactory)
register(SlackTeamIdentityFactory)
register(SlackMessageFactory)
register(TelegramToUserConnectorFactory)
register(TelegramChannelFactory)
register(TelegramVerificationCodeFactory)
register(TelegramChannelVerificationCodeFactory)
register(TelegramMessageFactory)
register(ResolutionNoteSlackMessageFactory)
register(PhoneCallRecordFactory)
register(SMSRecordFactory)
register(EmailMessageFactory)
register(IntegrationHeartBeatFactory)
register(LiveSettingFactory)
register(LabelKeyFactory)
register(LabelValueFactory)
register(AlertReceiveChannelAssociatedLabelFactory)
register(GoogleOAuth2UserFactory)
IS_RBAC_ENABLED = os.getenv("ONCALL_TESTING_RBAC_ENABLED", "True") == "True"
@ -283,6 +283,14 @@ def make_slack_token_for_user():
return _make_slack_token_for_user
@pytest.fixture
def make_google_oauth2_token_for_user():
def _make_google_oauth2_token_for_user(user):
return GoogleOAuth2Token.create_auth_token(organization=user.organization, user=user)
return _make_google_oauth2_token_for_user
@pytest.fixture
def make_public_api_token():
def _make_public_api_token(user, organization, name="test_api_token"):
@ -1053,3 +1061,11 @@ def make_webhook_label_association(make_label_key_and_value):
return WebhookAssociatedLabelFactory(webhook=webhook, organization=organization, key=key, value=value, **kwargs)
return _make_integration_label_association
@pytest.fixture
def make_google_oauth2_user_for_user():
def _make_google_oauth2_user_for_user(user):
return GoogleOAuth2UserFactory(user=user)
return _make_google_oauth2_user_for_user

View file

@ -57,6 +57,8 @@ module = [
"factory.*",
"fcm_django.*",
"firebase_admin.*",
"googleapiclient.discovery.*",
"google.oauth2.credentials.*",
"httpretty.*",
"humanize.*",
"ipware.*",

View file

@ -61,3 +61,6 @@ twilio~=6.37.0
urllib3==1.26.18
uwsgi==2.0.21
whitenoise==5.3.0
google-api-python-client==2.122.0
google-auth-httplib2==0.2.0
google-auth-oauthlib==1.2.0

View file

@ -168,17 +168,24 @@ google-api-core==2.17.0
# google-cloud-core
# google-cloud-firestore
# google-cloud-storage
google-api-python-client==2.118.0
# via firebase-admin
google-api-python-client==2.122.0
# via
# -r ./engine/requirements.in
# firebase-admin
google-auth==2.27.0
# via
# google-api-core
# google-api-python-client
# google-auth-httplib2
# google-auth-oauthlib
# google-cloud-core
# google-cloud-storage
google-auth-httplib2==0.2.0
# via google-api-python-client
# via
# -r ./engine/requirements.in
# google-api-python-client
google-auth-oauthlib==1.2.0
# via -r ./engine/requirements.in
google-cloud-core==2.4.1
# via
# google-cloud-firestore
@ -397,7 +404,9 @@ requests==2.31.0
# social-auth-core
# twilio
requests-oauthlib==1.3.1
# via social-auth-core
# via
# google-auth-oauthlib
# social-auth-core
rpds-py==0.18.0
# via
# jsonschema

View file

@ -71,6 +71,7 @@ GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATION
FEATURE_LABELS_ENABLED_FOR_ALL = getenv_boolean("FEATURE_LABELS_ENABLED_FOR_ALL", default=False)
# Enable labels feature for organizations from the list. Use OnCall organization ID, for this flag
FEATURE_LABELS_ENABLED_PER_ORG = getenv_list("FEATURE_LABELS_ENABLED_PER_ORG", default=list())
FEATURE_GOOGLE_OAUTH2_ENABLED = getenv_boolean("FEATURE_GOOGLE_OAUTH2_ENABLED", default=False)
TWILIO_API_KEY_SID = os.environ.get("TWILIO_API_KEY_SID")
TWILIO_API_KEY_SECRET = os.environ.get("TWILIO_API_KEY_SECRET")
@ -280,6 +281,7 @@ INSTALLED_APPS = [
"django_dbconn_retry",
"apps.phone_notifications",
"drf_spectacular",
"apps.google",
]
REST_FRAMEWORK = {
@ -320,6 +322,10 @@ SPECTACULAR_INCLUDED_PATHS = [
"/alert_receive_channels",
"/users",
"/labels",
# social auth routes
"/login",
"/complete",
"/disconnect",
]
MIDDLEWARE = [
@ -642,8 +648,27 @@ AUTHENTICATION_BACKENDS = [
"apps.social_auth.backends.InstallSlackOAuth2V2",
"apps.social_auth.backends.LoginSlackOAuth2V2",
"django.contrib.auth.backends.ModelBackend",
"apps.social_auth.backends.GoogleOAuth2",
]
if FEATURE_GOOGLE_OAUTH2_ENABLED:
CELERY_BEAT_SCHEDULE["sync_google_calendar_out_of_office_events_for_all_users"] = {
"task": "apps.google.tasks.sync_out_of_office_calendar_events_for_all_users",
"schedule": crontab(minute="*/30"), # every 30 minutes
"args": (),
}
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.environ.get("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY")
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.environ.get("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET")
# NOTE: for right now we probably only need the calendar.events.readonly scope
# however, if we want to write events back to the user's calendar
# we'll probably need to change this to the calendar.events scope
# (not sure how hard this is to migrate to in the future?)
# https://developers.google.com/identity/protocols/oauth2/scopes#calendar
SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = getenv_list(
"SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE", default=["https://www.googleapis.com/auth/calendar.events.readonly"]
)
SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET")
SLACK_SIGNING_SECRET_LIVE = os.environ.get("SLACK_SIGNING_SECRET_LIVE", "")
@ -673,13 +698,32 @@ SOCIAL_AUTH_SLACK_INSTALL_FREE_CUSTOM_SCOPE = [
]
SOCIAL_AUTH_PIPELINE = (
"apps.social_auth.pipeline.set_user_and_organization_from_request",
"apps.social_auth.pipeline.common.set_user_and_organization_from_request",
"social_core.pipeline.social_auth.social_details",
"apps.social_auth.pipeline.connect_user_to_slack",
"apps.social_auth.pipeline.populate_slack_identities",
"apps.social_auth.pipeline.delete_slack_auth_token",
"apps.social_auth.pipeline.slack.connect_user_to_slack",
"apps.social_auth.pipeline.slack.populate_slack_identities",
"apps.social_auth.pipeline.slack.delete_slack_auth_token",
)
SOCIAL_AUTH_GOOGLE_OAUTH2_PIPELINE = (
"apps.social_auth.pipeline.common.set_user_and_organization_from_request",
"apps.social_auth.pipeline.google.persist_access_and_refresh_tokens",
)
SOCIAL_AUTH_GOOGLE_OAUTH2_DISCONNECT_PIPELINE = (
"apps.social_auth.pipeline.common.set_user_and_organization_from_request",
"apps.social_auth.pipeline.google.disconnect_user_google_oauth2_settings",
)
# https://python-social-auth.readthedocs.io/en/latest/use_cases.html#re-prompt-google-oauth2-users-to-refresh-the-refresh-token
# https://developers.google.com/identity/protocols/oauth2/web-server
SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS = {
# Indicates whether your application can refresh access tokens when the user is not present at the browser.
# Valid parameter values are online, which is the default value, and offline.
"access_type": "offline",
"approval_prompt": "auto",
}
SOCIAL_AUTH_FIELDS_STORED_IN_SESSION: typing.List[str] = []
SOCIAL_AUTH_REDIRECT_IS_HTTPS = getenv_boolean("SOCIAL_AUTH_REDIRECT_IS_HTTPS", default=True)
SOCIAL_AUTH_SLUGIFY_USERNAMES = True
@ -689,7 +733,7 @@ PUBLIC_PRIMARY_KEY_MIN_LENGTH = 12
PUBLIC_PRIMARY_KEY_ALLOWED_CHARS = "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789"
AUTH_LINK_TIMEOUT_SECONDS = 300
SLACK_AUTH_TOKEN_TIMEOUT_SECONDS = 300
AUTH_TOKEN_TIMEOUT_SECONDS = 300
SLACK_INSTALL_RETURN_REDIRECT_HOST = os.environ.get("SLACK_INSTALL_RETURN_REDIRECT_HOST", None)

View file

@ -113,6 +113,8 @@ CELERY_TASK_ROUTES = {
"apps.base.tasks.process_failed_to_invoke_celery_tasks": {"queue": "critical"},
"apps.base.tasks.process_failed_to_invoke_celery_tasks_batch": {"queue": "critical"},
"apps.email.tasks.notify_user_async": {"queue": "critical"},
"apps.google.tasks.sync_out_of_office_calendar_events_for_all_users": {"queue": "critical"},
"apps.google.tasks.sync_out_of_office_calendar_events_for_user": {"queue": "critical"},
"apps.integrations.tasks.create_alert": {"queue": "critical"},
"apps.integrations.tasks.create_alertmanager_alerts": {"queue": "critical"},
"apps.integrations.tasks.start_notify_about_integration_ratelimit": {"queue": "critical"},

View file

@ -20,7 +20,12 @@ test.skip(
'Insights is only available in Grafana 10.0.0 and above'
);
test.describe('Insights', () => {
/**
* skipping as these tests are currently flaky
* see this Slack conversation for more details:
* https://raintank-corp.slack.com/archives/C04JCU51NF8/p1712069772861909
*/
test.describe.skip('Insights', () => {
test.beforeAll(async ({ adminRolePage: { page } }) => {
const DATASOURCE_NAME = 'OnCall Prometheus';
const DATASOURCE_URL = 'http://oncall-dev-prometheus-server.default.svc.cluster.local';

View file

@ -0,0 +1,21 @@
import { AppFeature } from 'state/features';
import { test, expect } from '../fixtures';
import { goToOnCallPage } from '../utils/navigation';
test('Google Calendar connector and Google Calendar tab are visible if google_oauth2 feature enabled', async ({
adminRolePage: { page },
}) => {
goToOnCallPage(page, 'users/me');
const featuresResponse = await page.waitForResponse((resp) => {
return resp.url().includes('/features/') && resp.status() === 200;
});
const features = await featuresResponse.json();
if (features.includes(AppFeature.GoogleOauth2)) {
await expect(page.getByTestId('google-calendar-connector-title')).toBeVisible();
await expect(page.getByTestId('google-calendar-tab')).toBeVisible();
}
});

View file

@ -53,7 +53,7 @@ export const ServiceNowAuthSection: React.FC = observer(() => {
};
setIsAuthTestRunning(true);
const result = await AlertReceiveChannelHelper.testServiceNowAuthentication({ data });
const result = await AlertReceiveChannelHelper.testServiceNowAuthentication({ id: currentIntegration?.id, data });
setAuthTestResult(result);
setIsAuthTestRunning(false);
}

View file

@ -55,12 +55,14 @@ export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserIn
showTelegramConnectionTab,
showMobileAppConnectionTab,
showMsTeamsConnectionTab,
showGoogleCalendarTab,
] = [
!isDesktopOrLaptop,
isCurrent && organizationStore.currentOrganization?.slack_team_identity && !storeUser.slack_user_identity,
isCurrent && store.hasFeature(AppFeature.Telegram) && !storeUser.telegram_configuration,
isCurrent,
store.hasFeature(AppFeature.MsTeams) && !storeUser.messaging_backends.MSTEAMS,
isCurrent && store.hasFeature(AppFeature.GoogleOauth2),
];
const title = (
@ -81,6 +83,7 @@ export const UserSettings = observer(({ id, onHide, tab = UserSettingsTab.UserIn
showTelegramConnectionTab={showTelegramConnectionTab}
showMobileAppConnectionTab={showMobileAppConnectionTab}
showMsTeamsConnectionTab={showMsTeamsConnectionTab}
showGoogleCalendarTab={showGoogleCalendarTab}
/>
<TabsContent id={id} activeTab={activeTab} onTabChange={onTabChange} isDesktopOrLaptop={isDesktopOrLaptop} />
</div>

View file

@ -1,6 +1,7 @@
export enum UserSettingsTab {
UserInfo,
NotificationSettings,
GoogleCalendar,
PhoneVerification,
SlackInfo,
TelegramInfo,

View file

@ -10,6 +10,7 @@ import { MobileAppConnectionTab } from 'containers/MobileAppConnection/MobileApp
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab';
import { CloudPhoneSettings } from 'containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings';
import { GoogleCalendar } from 'containers/UserSettings/parts/tabs/GoogleCalendar/GoogleCalendar';
import { MSTeamsInfo } from 'containers/UserSettings/parts/tabs/MSTeamsInfo/MSTeamsInfo';
import { NotificationSettingsTab } from 'containers/UserSettings/parts/tabs/NotificationSettingsTab';
import { PhoneVerification } from 'containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification';
@ -29,6 +30,7 @@ interface TabsProps {
onTabChange: (tab: UserSettingsTab) => void;
showNotificationSettingsTab: boolean;
showMobileAppConnectionTab: boolean;
showGoogleCalendarTab: boolean;
showSlackConnectionTab: boolean;
showTelegramConnectionTab: boolean;
showMsTeamsConnectionTab: boolean;
@ -38,6 +40,7 @@ export const Tabs = ({
activeTab,
onTabChange,
showNotificationSettingsTab,
showGoogleCalendarTab,
showMobileAppConnectionTab,
showSlackConnectionTab,
showTelegramConnectionTab,
@ -70,6 +73,15 @@ export const Tabs = ({
data-testid="tab-notification-settings"
/>
)}
{showGoogleCalendarTab && (
<Tab
active={activeTab === UserSettingsTab.GoogleCalendar}
label="Google Calendar"
key={UserSettingsTab.GoogleCalendar}
onChangeTab={getTabClickHandler(UserSettingsTab.GoogleCalendar)}
data-testid="google-calendar-tab"
/>
)}
<Tab
active={activeTab === UserSettingsTab.PhoneVerification}
label="Phone Verification"
@ -147,6 +159,7 @@ export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLa
<UserInfoTab id={id} onTabChange={onTabChange} />
))}
{activeTab === UserSettingsTab.NotificationSettings && <NotificationSettingsTab id={id} />}
{activeTab === UserSettingsTab.GoogleCalendar && <GoogleCalendar id={id} />}
{activeTab === UserSettingsTab.PhoneVerification &&
(store.hasFeature(AppFeature.CloudNotifications) ? (
<CloudPhoneSettings userPk={id} />

View file

@ -0,0 +1,50 @@
import React from 'react';
import { Button, HorizontalGroup, InlineField } from '@grafana/ui';
import { observer } from 'mobx-react';
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { UserHelper } from 'models/user/user.helpers';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization/authorization';
interface GoogleConnectorProps {
id: ApiSchemas['User']['pk'];
}
export const GoogleConnector = observer((props: GoogleConnectorProps) => {
const { id } = props;
const store = useStore();
const { userStore } = store;
const storeUser = userStore.items[id];
const isCurrentUser = id === store.userStore.currentUserPk;
return (
<div>
<InlineField label="Google Account" labelWidth={15}>
{storeUser.has_google_oauth2_connected ? (
<HorizontalGroup spacing="xs">
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<WithConfirm title="Are you sure to disconnect your Google account?" confirmText="Disconnect">
<Button disabled={!isCurrentUser} variant="destructive" onClick={userStore.disconnectGoogle}>
Disconnect
</Button>
</WithConfirm>
</WithPermissionControlTooltip>
</HorizontalGroup>
) : (
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<Button disabled={!isCurrentUser} onClick={UserHelper.handleConnectGoogle}>
Connect account
</Button>
</WithPermissionControlTooltip>
)}
</InlineField>
</div>
);
});

View file

@ -0,0 +1,139 @@
import React, { useEffect, useState } from 'react';
import { css } from '@emotion/css';
import { Button, HorizontalGroup, InlineSwitch, VerticalGroup, useStyles2 } from '@grafana/ui';
import { observer } from 'mobx-react';
import { Block } from 'components/GBlock/Block';
import { Text } from 'components/Text/Text';
import { WithConfirm } from 'components/WithConfirm/WithConfirm';
import { GSelect } from 'containers/GSelect/GSelect';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import GoogleCalendarLogo from 'icons/GoogleCalendarLogo';
import { Schedule } from 'models/schedule/schedule.types';
import { UserHelper } from 'models/user/user.helpers';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization/authorization';
const GoogleCalendar: React.FC<{ id: ApiSchemas['User']['pk'] }> = observer(({ id }) => {
const { userStore, scheduleStore } = useStore();
const styles = useStyles2(getStyles);
const user = userStore.items[id];
const [googleCalendarSettings, setGoogleCalendarSettings] = useState(user?.google_calendar_settings);
const [showSchedulesDropdown, setShowSchedulesDropdown] = useState(
user.google_calendar_settings?.oncall_schedules_to_consider_for_shift_swaps?.length > 0
);
const handleShowSchedulesDropdownChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.checked;
setShowSchedulesDropdown(value);
if (!value) {
handleSchedulesChange([]);
}
};
useEffect(() => {
if (user) {
setGoogleCalendarSettings(user.google_calendar_settings);
}
}, [user]);
const handleSchedulesChange = (value) => {
setGoogleCalendarSettings((v) => ({ ...v, oncall_schedules_to_consider_for_shift_swaps: value }));
userStore.updateCurrentUser({
google_calendar_settings: { ...googleCalendarSettings, oncall_schedules_to_consider_for_shift_swaps: value },
});
};
return (
<VerticalGroup>
<Block bordered className={styles.root}>
<VerticalGroup>
{user.has_google_oauth2_connected ? (
<VerticalGroup>
<HorizontalGroup justify="space-between">
<HorizontalGroup>
<GoogleCalendarLogo width={32} height={32} />
<Text>Google calendar is connected</Text>
</HorizontalGroup>
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<WithConfirm title="Are you sure to disconnect your Google account?" confirmText="Disconnect">
<Button variant="destructive" onClick={userStore.disconnectGoogle}>
Disconnect
</Button>
</WithConfirm>
</WithPermissionControlTooltip>
</HorizontalGroup>
</VerticalGroup>
) : (
<HorizontalGroup justify="space-between">
<HorizontalGroup spacing="md">
<GoogleCalendarLogo width={32} height={32} />
<div>
<Text.Title level={5}>Connect your Google Calendar</Text.Title>
<Text type="secondary">
This connection allows Grafana OnCall to read your Out of Office events and autogenerate Shift Swap
Requests
</Text>
</div>
</HorizontalGroup>
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<Button variant="primary" onClick={UserHelper.handleConnectGoogle}>
Connect
</Button>
</WithPermissionControlTooltip>
</HorizontalGroup>
)}
{user.has_google_oauth2_connected && (
<VerticalGroup>
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<InlineSwitch
showLabel
label="Specify the schedules to sync with Google calendar"
value={showSchedulesDropdown}
transparent
onChange={handleShowSchedulesDropdownChange}
/>
</WithPermissionControlTooltip>
{showSchedulesDropdown && (
<div style={{ width: '100%' }}>
<WithPermissionControlTooltip userAction={UserActions.UserSettingsWrite}>
<GSelect<Schedule>
isMulti
showSearch
allowClear
disabled={false}
items={scheduleStore.items}
fetchItemsFn={scheduleStore.updateItems}
fetchItemFn={scheduleStore.updateItem}
getSearchResult={scheduleStore.getSearchResult}
displayField="name"
valueField="id"
placeholder="Select Schedules"
value={googleCalendarSettings?.oncall_schedules_to_consider_for_shift_swaps}
onChange={handleSchedulesChange}
/>
</WithPermissionControlTooltip>
</div>
)}
</VerticalGroup>
)}
</VerticalGroup>
</Block>
</VerticalGroup>
);
});
export const getStyles = () => ({
root: css({
width: '100%',
}),
});
export { GoogleCalendar };

View file

@ -1,7 +1,4 @@
.user-value {
font-size: 16px;
}
.user-item {
margin-bottom: 15px;
.external-link-style {
margin-right: 4px;
align-self: baseline;
}

View file

@ -5,7 +5,9 @@ import { InlineField, Input, Legend } from '@grafana/ui';
import { GrafanaTeamSelect } from 'containers/GrafanaTeamSelect/GrafanaTeamSelect';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { Connectors } from 'containers/UserSettings/parts/connectors/Connectors';
import { GoogleConnector } from 'containers/UserSettings/parts/connectors/GoogleConnector';
import { ApiSchemas } from 'network/oncall-api/api.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
interface UserInfoTabProps {
@ -55,6 +57,12 @@ export const UserInfoTab = (props: UserInfoTabProps) => {
}}
/>
</InlineField>
{store.hasFeature(AppFeature.GoogleOauth2) && (
<>
<Legend data-testid="google-calendar-connector-title">Google Calendar</Legend>
<GoogleConnector {...props} />
</>
)}
<Legend>Notification channels</Legend>
<Connectors {...props} />
</>

View file

@ -0,0 +1,53 @@
import React from 'react';
const GoogleCalendarLogo = ({ width, height }) => (
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 200 200"
enableBackground="new 0 0 200 200"
width={`${width}px`}
height={`${height}px`}
>
<g>
<g transform="translate(3.75 3.75)">
<path
fill="#FFFFFF"
d="M148.882,43.618l-47.368-5.263l-57.895,5.263L38.355,96.25l5.263,52.632l52.632,6.579l52.632-6.579
l5.263-53.947L148.882,43.618z"
/>
<path
fill="#1A73E8"
d="M65.211,125.276c-3.934-2.658-6.658-6.539-8.145-11.671l9.132-3.763c0.829,3.158,2.276,5.605,4.342,7.342
c2.053,1.737,4.553,2.592,7.474,2.592c2.987,0,5.553-0.908,7.697-2.724s3.224-4.132,3.224-6.934c0-2.868-1.132-5.211-3.395-7.026
s-5.105-2.724-8.5-2.724h-5.276v-9.039H76.5c2.921,0,5.382-0.789,7.382-2.368c2-1.579,3-3.737,3-6.487
c0-2.447-0.895-4.395-2.684-5.855s-4.053-2.197-6.803-2.197c-2.684,0-4.816,0.711-6.395,2.145s-2.724,3.197-3.447,5.276
l-9.039-3.763c1.197-3.395,3.395-6.395,6.618-8.987c3.224-2.592,7.342-3.895,12.342-3.895c3.697,0,7.026,0.711,9.974,2.145
c2.947,1.434,5.263,3.421,6.934,5.947c1.671,2.539,2.5,5.382,2.5,8.539c0,3.224-0.776,5.947-2.329,8.184
c-1.553,2.237-3.461,3.947-5.724,5.145v0.539c2.987,1.25,5.421,3.158,7.342,5.724c1.908,2.566,2.868,5.632,2.868,9.211
s-0.908,6.776-2.724,9.579c-1.816,2.803-4.329,5.013-7.513,6.618c-3.197,1.605-6.789,2.421-10.776,2.421
C73.408,129.263,69.145,127.934,65.211,125.276z"
/>
<path
fill="#1A73E8"
d="M121.25,79.961l-9.974,7.25l-5.013-7.605l17.987-12.974h6.895v61.197h-9.895L121.25,79.961z"
/>
<path
fill="#EA4335"
d="M148.882,196.25l47.368-47.368l-23.684-10.526l-23.684,10.526l-10.526,23.684L148.882,196.25z"
/>
<path fill="#34A853" d="M33.092,172.566l10.526,23.684h105.263v-47.368H43.618L33.092,172.566z" />
<path
fill="#4285F4"
d="M12.039-3.75C3.316-3.75-3.75,3.316-3.75,12.039v136.842l23.684,10.526l23.684-10.526V43.618h105.263
l10.526-23.684L148.882-3.75H12.039z"
/>
<path fill="#188038" d="M-3.75,148.882v31.579c0,8.724,7.066,15.789,15.789,15.789h31.579v-47.368H-3.75z" />
<path fill="#FBBC04" d="M148.882,43.618v105.263h47.368V43.618l-23.684-10.526L148.882,43.618z" />
<path fill="#1967D2" d="M196.25,43.618V12.039c0-8.724-7.066-15.789-15.789-15.789h-31.579v47.368H196.25z" />
</g>
</g>
</svg>
);
export default GoogleCalendarLogo;

View file

@ -0,0 +1,26 @@
import React from 'react';
const GoogleLogo = ({ width, height }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={`${width}px`} height={`${height}px`}>
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
<path
fill="#4285F4"
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
/>
<path
fill="#34A853"
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
/>
<path
fill="#FBBC05"
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
/>
<path
fill="#EA4335"
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
/>
</g>
</svg>
);
export default GoogleLogo;

View file

@ -74,14 +74,20 @@ export class AlertReceiveChannelHelper {
}
static async testServiceNowAuthentication({
id,
data,
}: {
id: ApiSchemas['AlertReceiveChannel']['id'];
data: OmitReadonlyMembers<ApiSchemas['AlertReceiveChannelUpdate']>;
}) {
try {
const result = await onCallApi({ skipErrorHandling: false }).POST('/alert_receive_channels/test_connection/', {
const endpoint = id
? '/alert_receive_channels/{id}/test_connection/'
: '/alert_receive_channels/test_connection/';
const result = await onCallApi({ skipErrorHandling: false }).POST(endpoint, {
body: data as ApiSchemas['AlertReceiveChannelUpdate'],
params: {},
params: { path: { id } },
});
return result?.response.status === 200;
} catch (ex) {

View file

@ -107,4 +107,9 @@ export class UserHelper {
})
).data;
}
static async handleConnectGoogle() {
const { data } = await onCallApi().GET('/login/{backend}', { params: { path: { backend: 'google-oauth2' } } });
window.location = data;
}
}

View file

@ -143,6 +143,12 @@ export class UserStore {
this.loadCurrentUser();
}
async disconnectGoogle() {
await onCallApi().GET('/disconnect/{backend}', { params: { path: { backend: 'google-oauth2' } } });
this.loadCurrentUser();
}
async updateUser(data: Partial<ApiSchemas['User']>) {
const user = (
await onCallApi().PUT('/users/{id}/', {

View file

@ -849,6 +849,39 @@ export interface paths {
patch?: never;
trace?: never;
};
'/complete/{backend}/': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Authentication complete view */
get: operations['complete_retrieve'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/disconnect/{backend}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['disconnect_retrieve'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/features/': {
parameters: {
query?: never;
@ -954,6 +987,38 @@ export interface paths {
patch?: never;
trace?: never;
};
'/login/{backend}': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['login_retrieve'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/login/{backend}/': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations['login_retrieve_2'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/users/': {
parameters: {
query?: never;
@ -1732,6 +1797,9 @@ export interface components {
value: string;
display_name: string;
};
GoogleCalendarSettings: {
oncall_schedules_to_consider_for_shift_swaps?: string[] | null;
};
/** @description Alert group labels configuration for the integration. See AlertReceiveChannel.alert_group_labels for details. */
IntegrationAlertGroupLabels: {
inheritable: {
@ -1887,6 +1955,7 @@ export interface components {
};
readonly cloud_connection_status: components['schemas']['CloudConnectionStatusEnum'] | null;
hide_phone_number?: boolean;
readonly has_google_oauth2_connected: boolean;
};
/**
* @description * `0` - Debug
@ -2018,7 +2087,9 @@ export interface components {
};
readonly cloud_connection_status?: components['schemas']['CloudConnectionStatusEnum'] | null;
hide_phone_number?: boolean;
readonly has_google_oauth2_connected?: boolean;
readonly is_currently_oncall?: boolean;
google_calendar_settings?: components['schemas']['GoogleCalendarSettings'];
};
PreviewTemplateRequest: {
template_body?: string | null;
@ -2143,7 +2214,9 @@ export interface components {
};
readonly cloud_connection_status: components['schemas']['CloudConnectionStatusEnum'] | null;
hide_phone_number?: boolean;
readonly has_google_oauth2_connected: boolean;
readonly is_currently_oncall: boolean;
google_calendar_settings?: components['schemas']['GoogleCalendarSettings'];
};
UserExportTokenGetResponse: {
/** Format: date-time */
@ -3741,6 +3814,46 @@ export interface operations {
};
};
};
complete_retrieve: {
parameters: {
query?: never;
header?: never;
path: {
backend: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No response body */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
disconnect_retrieve: {
parameters: {
query?: never;
header?: never;
path: {
backend: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No response body */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
features_retrieve: {
parameters: {
query?: never;
@ -3764,6 +3877,7 @@ export interface operations {
| 'grafana_cloud_connection'
| 'grafana_alerting_v2'
| 'labels'
| 'google_oauth2'
)[];
};
};
@ -3938,6 +4052,46 @@ export interface operations {
};
};
};
login_retrieve: {
parameters: {
query?: never;
header?: never;
path: {
backend: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No response body */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
login_retrieve_2: {
parameters: {
query?: never;
header?: never;
path: {
backend: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No response body */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
users_list: {
parameters: {
query?: {

View file

@ -62,12 +62,13 @@ import { ApiSchemas } from 'network/oncall-api/api.types';
import { IntegrationHelper, getIsBidirectionalIntegration } from 'pages/integration/Integration.helper';
import styles from 'pages/integration/Integration.module.scss';
import { AppFeature } from 'state/features';
import { PageProps, SelectOption, WithStoreProps } from 'state/types';
import { PageProps, SelectOption, WithDrawerConfig, WithStoreProps } from 'state/types';
import { useStore } from 'state/useStore';
import { withMobXProviderContext } from 'state/withStore';
import { LocationHelper } from 'utils/LocationHelper';
import { UserActions } from 'utils/authorization/authorization';
import { PLUGIN_ROOT } from 'utils/consts';
import { withDrawer } from 'utils/hoc';
import { useDrawer } from 'utils/hooks';
import { getItem, setItem } from 'utils/localStorage';
import { sanitize } from 'utils/sanitize';
@ -77,7 +78,11 @@ import { OutgoingTab } from './OutgoingTab/OutgoingTab';
const cx = cn.bind(styles);
interface IntegrationProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
interface IntegrationProps
extends WithDrawerConfig<IntegrationDrawerKey>,
WithStoreProps,
PageProps,
RouteComponentProps<{ id: string }> {}
interface IntegrationState extends PageBaseState {
isLoading: boolean;
@ -138,6 +143,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
match: {
params: { id },
},
drawerConfig,
} = this.props;
const { alertReceiveChannelStore } = store;
@ -227,6 +233,7 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
alertReceiveChannel={alertReceiveChannel}
changeIsTemplateSettingsOpen={() => this.setState({ isTemplateSettingsOpen: true })}
isLegacyIntegration={isLegacyIntegration}
drawerConfig={drawerConfig}
/>
</div>
@ -266,7 +273,10 @@ class _IntegrationPage extends React.Component<IntegrationProps, IntegrationStat
<Tabs
tabs={[
{ label: 'Incoming', content: incomingPart },
{ label: 'Outgoing', content: <OutgoingTab /> },
{
label: 'Outgoing',
content: <OutgoingTab openSnowConfigurationDrawer={() => drawerConfig.openDrawer('servicenow')} />,
},
]}
/>
) : (
@ -807,6 +817,7 @@ interface IntegrationActionsProps {
isLegacyIntegration: boolean;
alertReceiveChannel: ApiSchemas['AlertReceiveChannel'];
changeIsTemplateSettingsOpen: () => void;
drawerConfig: ReturnType<typeof useDrawer<IntegrationDrawerKey>>;
}
type IntegrationDrawerKey = 'servicenow' | 'completeConfig';
@ -815,6 +826,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
alertReceiveChannel,
isLegacyIntegration,
changeIsTemplateSettingsOpen,
drawerConfig,
}) => {
const store = useStore();
const { alertReceiveChannelStore } = store;
@ -842,7 +854,7 @@ const IntegrationActions: React.FC<IntegrationActionsProps> = ({
alert_receive_channel_id: ApiSchemas['AlertReceiveChannel']['id'];
}>(undefined);
const { closeDrawer, openDrawer, getIsDrawerOpened } = useDrawer<IntegrationDrawerKey>();
const { closeDrawer, openDrawer, getIsDrawerOpened } = drawerConfig;
const { id } = alertReceiveChannel;
@ -1274,4 +1286,4 @@ const IntegrationHeader: React.FC<IntegrationHeaderProps> = ({
}
};
export const IntegrationPage = withRouter(withMobXProviderContext(_IntegrationPage));
export const IntegrationPage = withRouter(withMobXProviderContext(withDrawer<IntegrationDrawerKey>(_IntegrationPage)));

View file

@ -1,6 +1,6 @@
import React from 'react';
import { useStyles2, Input, IconButton, Drawer, Badge, HorizontalGroup } from '@grafana/ui';
import { useStyles2, Input, IconButton, Drawer, HorizontalGroup } from '@grafana/ui';
import { observer } from 'mobx-react';
import { Button } from 'components/Button/Button';
@ -19,7 +19,7 @@ import { OutgoingTabDrawerKey } from './OutgoingTab.types';
import { OutgoingWebhookDetailsDrawerTabs } from './OutgoingWebhookDetailsDrawerTabs';
import { OutgoingWebhooksTable } from './OutgoingWebhooksTable';
export const OutgoingTab = () => {
export const OutgoingTab = ({ openSnowConfigurationDrawer }: { openSnowConfigurationDrawer: () => void }) => {
const { openDrawer, closeDrawer, getIsDrawerOpened } = useDrawer<OutgoingTabDrawerKey>();
const styles = useStyles2(getStyles);
@ -45,7 +45,7 @@ export const OutgoingTab = () => {
{
customIcon: 'plug',
startingElemPosition: '50%',
expandedView: () => <Connection />,
expandedView: () => <Connection openSnowConfigurationDrawer={openSnowConfigurationDrawer} />,
},
{
customIcon: 'plus',
@ -79,9 +79,8 @@ export const OutgoingTab = () => {
);
};
const Connection = observer(() => {
const Connection = observer(({ openSnowConfigurationDrawer }: { openSnowConfigurationDrawer: () => void }) => {
const styles = useStyles2(getStyles);
const integration = useCurrentIntegration();
// TODO: remove casting once backend narrows down the types
const url = integration?.additional_settings?.instance_url as string;
@ -94,7 +93,6 @@ const Connection = observer(() => {
heading={
<div className={styles.horizontalGroup}>
<IntegrationTag>ServiceNow connection</IntegrationTag>
<Badge text="OK" color="green" />
<Input
value={url}
disabled
@ -118,6 +116,7 @@ const Connection = observer(() => {
name="cog"
aria-label="Open ServiceNow configuration"
className={styles.openConfigurationBtn}
onClick={openSnowConfigurationDrawer}
/>
</div>
}

View file

@ -5,7 +5,6 @@ import {
Field,
HorizontalGroup,
Icon,
Input,
Label,
Select,
Switch,
@ -17,7 +16,7 @@ import cn from 'classnames';
import { Controller, useFormContext } from 'react-hook-form';
import { MonacoEditor } from 'components/MonacoEditor/MonacoEditor';
import { MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
import { MONACO_EDITABLE_CONFIG, MONACO_READONLY_CONFIG } from 'components/MonacoEditor/MonacoEditor.config';
import { WebhooksTemplateEditor } from 'containers/WebhooksTemplateEditor/WebhooksTemplateEditor';
import { HTTP_METHOD_OPTIONS, WEBHOOK_TRIGGGER_TYPE_OPTIONS } from 'models/outgoing_webhook/outgoing_webhook.types';
@ -37,7 +36,7 @@ interface OutgoingWebhookFormFieldsProps {
export const OutgoingWebhookFormFields: FC<OutgoingWebhookFormFieldsProps> = ({ webhookId }) => {
const styles = useStyles2(getStyles);
const { control, watch, formState, register } = useFormContext<OutgoingTabFormValues>();
const { control, watch, formState } = useFormContext<OutgoingTabFormValues>();
const [templateToEdit, setTemplateToEdit] = useState<TemplateToEdit>();
const [showTriggerTemplate] = watch(['triggerTemplateToogle']);
@ -83,26 +82,6 @@ export const OutgoingWebhookFormFields: FC<OutgoingWebhookFormFieldsProps> = ({
</Field>
)}
/>
<Field
key="url"
invalid={Boolean(formState.errors.url)}
error={formState.errors.url?.message}
label={
<Label>
<span>Webhook URL</span>&nbsp;
<Tooltip content="Some description" placement="right">
<Icon name="info-circle" className={styles.infoIcon} />
</Tooltip>
</Label>
}
className={styles.selectField}
>
<Input
{...register('url', {
required: 'URL is required',
})}
/>
</Field>
<Controller
control={control}
name="http_method"
@ -134,6 +113,52 @@ export const OutgoingWebhookFormFields: FC<OutgoingWebhookFormFieldsProps> = ({
</Field>
)}
/>
<Controller
control={control}
name="url"
render={({ field }) => (
<VerticalGroup>
<HorizontalGroup width="100%" justify="space-between">
<Label>
<span>Webhook URL</span>&nbsp;
<Tooltip content="Some description" placement="right">
<Icon name="info-circle" className={styles.infoIcon} />
</Tooltip>
</Label>
<Button
icon="edit"
variant="secondary"
onClick={() => {
setTemplateToEdit({
value: field.value,
displayName: 'webhook url',
name: field.name,
});
}}
/>
</HorizontalGroup>
<MonacoEditor
{...field}
data={{}} // TODO:update
showLineNumbers={false}
height={30}
monacoOptions={MONACO_EDITABLE_CONFIG}
onChange={field.onChange}
/>
{templateToEdit?.['name'] === field.name && (
<WebhooksTemplateEditor
id={webhookId}
handleSubmit={(value) => {
field.onChange(value);
setTemplateToEdit(undefined);
}}
onHide={() => setTemplateToEdit(undefined)}
template={templateToEdit}
/>
)}
</VerticalGroup>
)}
/>
<Controller
control={control}
name="data"

View file

@ -6,4 +6,5 @@ export enum AppFeature {
CloudConnection = 'grafana_cloud_connection',
Labels = 'labels',
MsTeams = 'msteams',
GoogleOauth2 = 'google_oauth2',
}

View file

@ -1,11 +1,16 @@
import { AppPluginMeta, KeyValue } from '@grafana/data';
import { RootStore } from 'state/rootStore';
import { useDrawer } from 'utils/hooks';
export interface WithStoreProps {
store: RootStore;
}
export interface WithDrawerConfig<T extends string> {
drawerConfig: ReturnType<typeof useDrawer<T>>;
}
export interface PageProps<T extends KeyValue = KeyValue> {
meta: AppPluginMeta<T>;
query: KeyValue;

View file

@ -0,0 +1,11 @@
import React from 'react';
import { useDrawer } from './hooks';
export const withDrawer = <T extends string>(Component: React.ComponentType<any>) => {
const ComponentWithDrawer = (props: any) => {
const drawerConfig = useDrawer<T>();
return <Component {...props} drawerConfig={drawerConfig} />;
};
return ComponentWithDrawer;
};