Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AAP-15464: Enable Opt-Out Telemetry (the UI/UX to enable) #718

Merged
merged 4 commits into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ create-superuser-containerized:
migrate:
python ansible_wisdom/manage.py migrate

.PHONY: makemigrations
makemigrations:
python ansible_wisdom/manage.py makemigrations

.PHONY: create-cachetable
create-cachetable: migrate
python ansible_wisdom/manage.py createcachetable
Expand Down
25 changes: 25 additions & 0 deletions ansible_wisdom/ai/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,3 +511,28 @@ class Meta:
label='Model Id',
help_text='WCA Model Id.',
)


@extend_schema_serializer(
examples=[
OpenApiExample(
'Valid example',
summary='Request Telemetry settings',
description='A valid request to set the Telemetry settings.',
value={
'optOut': 'true',
},
request_only=True,
response_only=False,
),
]
)
class TelemetrySettingsRequestSerializer(serializers.Serializer):
class Meta:
fields = ['optOut']

optOut = serializers.BooleanField(
required=True,
label='OptOut',
help_text='Indicates whether the Red Hat Organization opts out of telemetry collection.',
)
Empty file.
148 changes: 148 additions & 0 deletions ansible_wisdom/ai/api/telemetry/api_telemetry_settings_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import logging
import time

from ai.api.permissions import (
IsOrganisationAdministrator,
IsOrganisationLightspeedSubscriber,
)
from ai.api.serializers import TelemetrySettingsRequestSerializer
from ai.api.utils.segment import send_segment_event
from ai.api.views import InternalServerError, ServiceUnavailable
from django.conf import settings
from drf_spectacular.utils import OpenApiResponse, extend_schema
from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope
from rest_framework.exceptions import ValidationError
from rest_framework.generics import CreateAPIView, RetrieveAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST
from users.signals import user_set_telemetry_settings

logger = logging.getLogger(__name__)

PERMISSION_CLASSES = [
IsAuthenticated,
IsAuthenticatedOrTokenHasScope,
IsOrganisationAdministrator,
IsOrganisationLightspeedSubscriber,
]


class TelemetrySettingsView(RetrieveAPIView, CreateAPIView):
required_scopes = ['read', 'write']
throttle_cache_key_suffix = '_telemetry_settings'
permission_classes = PERMISSION_CLASSES

@extend_schema(
responses={
200: OpenApiResponse(description='OK'),
400: OpenApiResponse(description='Bad Request'),
401: OpenApiResponse(description='Unauthorized'),
403: OpenApiResponse(description='Forbidden'),
429: OpenApiResponse(description='Request was throttled'),
500: OpenApiResponse(description='Internal service error'),
},
summary="Get the telemetry settings for an Organisation",
operation_id="telemetry_settings_get",
)
def get(self, request, *args, **kwargs):
logger.debug("Telemetry settings:: GET handler")

if not settings.ADMIN_PORTAL_TELEMETRY_OPT_ENABLED:
raise ServiceUnavailable()
manstis marked this conversation as resolved.
Show resolved Hide resolved

exception = None
organization = None
start_time = time.time()
try:
# An OrgId must be present
# See https://issues.redhat.com/browse/AAP-16009
organization = request._request.user.organization
if not organization:
return Response(status=HTTP_400_BAD_REQUEST)

return Response(status=HTTP_200_OK, data={'optOut': organization.telemetry_opt_out})

except Exception as e:
exception = e
logger.exception(e)
raise InternalServerError(e)

finally:
duration = round((time.time() - start_time) * 1000, 2)
event = {
"duration": duration,
"exception": exception is not None,
"problem": None if exception is None else exception.__class__.__name__,
"opt_out": None if organization is None else organization.telemetry_opt_out,
}
send_segment_event(event, "telemetrySettingsGet", request.user)
manstis marked this conversation as resolved.
Show resolved Hide resolved

@extend_schema(
request=TelemetrySettingsRequestSerializer,
responses={
204: OpenApiResponse(description='Empty response'),
400: OpenApiResponse(description='Bad request'),
401: OpenApiResponse(description='Unauthorized'),
403: OpenApiResponse(description='Forbidden'),
429: OpenApiResponse(description='Request was throttled'),
500: OpenApiResponse(description='Internal service error'),
},
summary="Set the Telemetry settings for an Organisation",
operation_id="telemetry_settings_set",
)
def post(self, request, *args, **kwargs):
logger.debug("Telemetry settings:: POST handler")

if not settings.ADMIN_PORTAL_TELEMETRY_OPT_ENABLED:
raise ServiceUnavailable()

exception = None
organization = None
start_time = time.time()
try:
# An OrgId must be present
# See https://issues.redhat.com/browse/AAP-16009
organization = request._request.user.organization
if not organization:
return Response(status=HTTP_400_BAD_REQUEST)

# Extract Telemetry settings from request
telemetry_settings_serializer = TelemetrySettingsRequestSerializer(data=request.data)
telemetry_settings_serializer.is_valid(raise_exception=True)
telemetry_settings = telemetry_settings_serializer.validated_data

# Store the Opt-Out setting
organization.telemetry_opt_out = telemetry_settings["optOut"]
organization.save()

# Audit trail/logging
user_set_telemetry_settings.send(
TelemetrySettingsView.__class__,
user=request._request.user,
org_id=organization.id,
settings=telemetry_settings,
)
logger.info(f"Stored telemetry settings for org_id '{organization.id}'")

except ValidationError as e:
exception = e
logger.info(e, exc_info=True)
return Response(status=HTTP_400_BAD_REQUEST)

except Exception as e:
exception = e
logger.exception(e)
raise InternalServerError(cause=e)

finally:
duration = round((time.time() - start_time) * 1000, 2)
event = {
"duration": duration,
manstis marked this conversation as resolved.
Show resolved Hide resolved
"exception": exception is not None,
"problem": None if exception is None else exception.__class__.__name__,
"opt_out": None if organization is None else organization.telemetry_opt_out,
}
send_segment_event(event, "telemetrySettingsSet", request.user)
manstis marked this conversation as resolved.
Show resolved Hide resolved

return Response(status=HTTP_204_NO_CONTENT)
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
from http import HTTPStatus
from unittest.mock import patch

from ai.api.permissions import (
IsOrganisationAdministrator,
IsOrganisationLightspeedSubscriber,
)
from ai.api.tests.test_views import WisdomServiceAPITestCaseBase
from django.db.utils import DatabaseError
from django.test import override_settings
from django.urls import resolve, reverse
from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope
from organizations.models import Organization
from rest_framework.permissions import IsAuthenticated


@patch.object(IsOrganisationAdministrator, 'has_permission', return_value=True)
@patch.object(IsOrganisationLightspeedSubscriber, 'has_permission', return_value=True)
class TestTelemetrySettingsView(WisdomServiceAPITestCaseBase):
def test_get_settings_authentication_error(self, *args):
# self.client.force_authenticate(user=self.user)
r = self.client.get(reverse('telemetry_settings'))
self.assertEqual(r.status_code, HTTPStatus.UNAUTHORIZED)

def test_permission_classes(self, *args):
url = reverse('telemetry_settings')
view = resolve(url).func.view_class

required_permissions = [
IsAuthenticated,
IsAuthenticatedOrTokenHasScope,
IsOrganisationAdministrator,
IsOrganisationLightspeedSubscriber,
]
self.assertEqual(len(view.permission_classes), len(required_permissions))
for permission in required_permissions:
self.assertTrue(permission in view.permission_classes)

@override_settings(ADMIN_PORTAL_TELEMETRY_OPT_ENABLED=False)
def test_get_settings_when_feature_disabled(self, *args):
self.client.force_authenticate(user=self.user)
r = self.client.get(reverse('telemetry_settings'))
self.assertEqual(r.status_code, HTTPStatus.SERVICE_UNAVAILABLE)

@override_settings(SEGMENT_WRITE_KEY='DUMMY_KEY_VALUE')
def test_get_settings_without_org_id(self, *args):
self.client.force_authenticate(user=self.user)

with self.assertLogs(logger='root', level='DEBUG') as log:
r = self.client.get(reverse('telemetry_settings'))
self.assertEqual(r.status_code, HTTPStatus.BAD_REQUEST)
self.assert_segment_log(log, "telemetrySettingsGet", None)

@override_settings(SEGMENT_WRITE_KEY='DUMMY_KEY_VALUE')
def test_get_settings_when_undefined(self, *args):
self.user.organization = Organization.objects.get_or_create(id=123)[0]
self.client.force_authenticate(user=self.user)

with self.assertLogs(logger='root', level='DEBUG') as log:
r = self.client.get(reverse('telemetry_settings'))
self.assertEqual(r.status_code, HTTPStatus.OK)
self.assertFalse(r.data['optOut'])
self.assert_segment_log(log, "telemetrySettingsGet", None, opt_out=False)

@override_settings(SEGMENT_WRITE_KEY='DUMMY_KEY_VALUE')
def test_get_settings_when_defined(self, *args):
self.user.organization = Organization.objects.get_or_create(id=123, telemetry_opt_out=True)[
0
]
self.client.force_authenticate(user=self.user)

with self.assertLogs(logger='root', level='DEBUG') as log:
r = self.client.get(reverse('telemetry_settings'))
self.assertEqual(r.status_code, HTTPStatus.OK)
self.assertTrue(r.data['optOut'])
self.assert_segment_log(log, "telemetrySettingsGet", None, opt_out=True)

def test_set_settings_authentication_error(self, *args):
# self.client.force_authenticate(user=self.user)
r = self.client.post(reverse('telemetry_settings'))
self.assertEqual(r.status_code, HTTPStatus.UNAUTHORIZED)

@override_settings(ADMIN_PORTAL_TELEMETRY_OPT_ENABLED=False)
def test_set_settings_when_feature_disabled(self, *args):
self.client.force_authenticate(user=self.user)
r = self.client.get(reverse('telemetry_settings'))
self.assertEqual(r.status_code, HTTPStatus.SERVICE_UNAVAILABLE)

@override_settings(SEGMENT_WRITE_KEY='DUMMY_KEY_VALUE')
def test_set_settings_without_org_id(self, *args):
self.client.force_authenticate(user=self.user)

with self.assertLogs(logger='root', level='DEBUG') as log:
r = self.client.post(reverse('telemetry_settings'))
self.assertEqual(r.status_code, HTTPStatus.BAD_REQUEST)
self.assert_segment_log(log, "telemetrySettingsSet", None)

@override_settings(SEGMENT_WRITE_KEY='DUMMY_KEY_VALUE')
def test_set_settings_with_valid_value(self, *args):
self.user.organization = Organization.objects.get_or_create(id=123)[0]
self.client.force_authenticate(user=self.user)

# Settings should initially be False
r = self.client.get(reverse('telemetry_settings'))
self.assertEqual(r.status_code, HTTPStatus.OK)
self.assertFalse(r.data['optOut'])

# Set settings
with self.assertLogs(logger='users.signals', level='DEBUG') as signals:
with self.assertLogs(logger='root', level='DEBUG') as log:
r = self.client.post(
reverse('telemetry_settings'),
data='{ "optOut": "True" }',
content_type='application/json',
)
self.assertEqual(r.status_code, HTTPStatus.NO_CONTENT)
self.assert_segment_log(log, "telemetrySettingsSet", None, opt_out=True)

# Check audit entry
self.assertInLog(
f"User: '{self.user}' set Telemetry settings for "
f"Organisation '{self.user.organization.id}'",
signals,
)

# Check Settings were stored
r = self.client.get(reverse('telemetry_settings'))
self.assertEqual(r.status_code, HTTPStatus.OK)
self.assertTrue(r.data['optOut'])

@override_settings(SEGMENT_WRITE_KEY='DUMMY_KEY_VALUE')
def test_set_settings_throws_exception(self, *args):
self.user.organization = Organization.objects.get_or_create(id=123)[0]
self.client.force_authenticate(user=self.user)

with patch("django.db.models.base.Model.save", side_effect=DatabaseError()):
with self.assertLogs(logger='root', level='DEBUG') as log:
r = self.client.post(
reverse('telemetry_settings'),
data='{ "optOut": "False" }',
content_type='application/json',
)
self.assertEqual(r.status_code, HTTPStatus.INTERNAL_SERVER_ERROR)
self.assert_segment_log(log, "telemetrySettingsSet", "DatabaseError", opt_out=False)

@override_settings(SEGMENT_WRITE_KEY='DUMMY_KEY_VALUE')
def test_set_settings_throws_validation_exception(self, *args):
self.user.organization = Organization.objects.get_or_create(id=123)[0]
self.client.force_authenticate(user=self.user)

with self.assertLogs(logger='root', level='DEBUG') as log:
r = self.client.post(
reverse('telemetry_settings'),
data='{ "unknown_json_field": "a-new-key" }',
content_type='application/json',
)
self.assertEqual(r.status_code, HTTPStatus.BAD_REQUEST)
self.assert_segment_log(log, "telemetrySettingsSet", "ValidationError", opt_out=False)


@patch.object(IsOrganisationAdministrator, 'has_permission', return_value=True)
@patch.object(IsOrganisationLightspeedSubscriber, 'has_permission', return_value=False)
class TestTelemetrySettingsViewAsNonSubscriber(WisdomServiceAPITestCaseBase):
def test_get_settings_as_non_subscriber(self, *args):
self.user.organization = Organization.objects.get_or_create(id=123)[0]
self.client.force_authenticate(user=self.user)
r = self.client.get(reverse('telemetry_settings'))
self.assertEqual(r.status_code, HTTPStatus.FORBIDDEN)
Loading