Skip to content

Commit

Permalink
AAP-20249: Admin Dashboard: [Feature flag] -M-A-G-I-C- Organizations
Browse files Browse the repository at this point in the history
  • Loading branch information
manstis committed Feb 7, 2024
1 parent b99e7d0 commit 08ac0d5
Show file tree
Hide file tree
Showing 22 changed files with 296 additions and 118 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,6 @@ ari/kb/rules/

# Generated Grafana files
grafana/*

# Local LaunchDarkly data
./flagdata.json
19 changes: 12 additions & 7 deletions ansible_wisdom/ai/api/telemetry/api_telemetry_settings_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
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
Expand Down Expand Up @@ -48,9 +47,6 @@ class TelemetrySettingsView(RetrieveAPIView, CreateAPIView):
def get(self, request, *args, **kwargs):
logger.debug("Telemetry settings:: GET handler")

if not settings.ADMIN_PORTAL_TELEMETRY_OPT_ENABLED:
raise ServiceUnavailable()

exception = None
organization = None
start_time = time.time()
Expand All @@ -61,8 +57,14 @@ def get(self, request, *args, **kwargs):
if not organization:
return Response(status=HTTP_400_BAD_REQUEST)

if not organization.is_schema_2_telemetry_enabled:
raise ServiceUnavailable()

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

except ServiceUnavailable:
raise

except Exception as e:
exception = e
logger.exception(e)
Expand Down Expand Up @@ -94,9 +96,6 @@ def get(self, request, *args, **kwargs):
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()
Expand All @@ -107,6 +106,9 @@ def post(self, request, *args, **kwargs):
if not organization:
return Response(status=HTTP_400_BAD_REQUEST)

if not organization.is_schema_2_telemetry_enabled:
raise ServiceUnavailable()

# Extract Telemetry settings from request
telemetry_settings_serializer = TelemetrySettingsRequestSerializer(data=request.data)
telemetry_settings_serializer.is_valid(raise_exception=True)
Expand All @@ -130,6 +132,9 @@ def post(self, request, *args, **kwargs):
logger.info(e, exc_info=True)
return Response(status=HTTP_400_BAD_REQUEST)

except ServiceUnavailable:
raise

except Exception as e:
exception = e
logger.exception(e)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from http import HTTPStatus
from unittest.mock import patch

import ai.feature_flags as feature_flags
from ai.api.permissions import (
IsOrganisationAdministrator,
IsOrganisationLightspeedSubscriber,
Expand Down Expand Up @@ -36,8 +37,11 @@ def test_permission_classes(self, *args):
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):
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
def test_get_settings_when_feature_disabled(self, LDClient, *args):
LDClient.return_value.variation.return_value = False
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.SERVICE_UNAVAILABLE)
Expand All @@ -52,7 +56,10 @@ def test_get_settings_without_org_id(self, *args):
self.assert_segment_log(log, "telemetrySettingsGet", None)

@override_settings(SEGMENT_WRITE_KEY='DUMMY_KEY_VALUE')
def test_get_settings_when_undefined(self, *args):
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
def test_get_settings_when_undefined(self, LDClient, *args):
LDClient.return_value.variation.return_value = True
self.user.organization = Organization.objects.get_or_create(id=123)[0]
self.client.force_authenticate(user=self.user)

Expand All @@ -63,7 +70,10 @@ def test_get_settings_when_undefined(self, *args):
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):
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
def test_get_settings_when_defined(self, LDClient, *args):
LDClient.return_value.variation.return_value = True
self.user.organization = Organization.objects.get_or_create(id=123, telemetry_opt_out=True)[
0
]
Expand All @@ -80,8 +90,11 @@ def test_set_settings_authentication_error(self, *args):
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):
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
def test_set_settings_when_feature_disabled(self, LDClient, *args):
LDClient.return_value.variation.return_value = False
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.SERVICE_UNAVAILABLE)
Expand All @@ -96,7 +109,10 @@ def test_set_settings_without_org_id(self, *args):
self.assert_segment_log(log, "telemetrySettingsSet", None)

@override_settings(SEGMENT_WRITE_KEY='DUMMY_KEY_VALUE')
def test_set_settings_with_valid_value(self, *args):
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
def test_set_settings_with_valid_value(self, LDClient, *args):
LDClient.return_value.variation.return_value = True
self.user.organization = Organization.objects.get_or_create(id=123)[0]
self.client.force_authenticate(user=self.user)

Expand Down Expand Up @@ -129,7 +145,10 @@ def test_set_settings_with_valid_value(self, *args):
self.assertTrue(r.data['optOut'])

@override_settings(SEGMENT_WRITE_KEY='DUMMY_KEY_VALUE')
def test_set_settings_throws_exception(self, *args):
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
def test_set_settings_throws_exception(self, LDClient, *args):
LDClient.return_value.variation.return_value = True
self.user.organization = Organization.objects.get_or_create(id=123)[0]
self.client.force_authenticate(user=self.user)

Expand All @@ -144,7 +163,10 @@ def test_set_settings_throws_exception(self, *args):
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):
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
def test_set_settings_throws_validation_exception(self, LDClient, *args):
LDClient.return_value.variation.return_value = True
self.user.organization = Organization.objects.get_or_create(id=123)[0]
self.client.force_authenticate(user=self.user)

Expand Down
14 changes: 9 additions & 5 deletions ansible_wisdom/ai/api/utils/segment_analytics_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from ai.api.utils.segment import base_send_segment_event, send_segment_event
from attr import asdict
from django.conf import settings
from organizations.models import Organization
from segment.analytics import Client
from users.models import User

Expand All @@ -29,15 +30,18 @@ def send_segment_analytics_event(event_enum, event_payload_supplier, user: User)
if not user.rh_user_has_seat:
logger.info("Skipping analytics telemetry event for users that has no seat.")
return
if not settings.ADMIN_PORTAL_TELEMETRY_OPT_ENABLED:
logger.info("Analytics telemetry not active.")
return
organization = user.organization

organization: Organization = user.organization
if not organization:
logger.info("Analytics telemetry not active, because of no organization assigned for user.")
return

if not organization.is_schema_2_telemetry_enabled:
logger.info(f"Analytics telemetry not active for organization '{organization.id}'.")
return

if organization.telemetry_opt_out:
logger.info("Analytics telemetry not active for organization.")
logger.info(f"Organization '{organization.id}' has opted out of Analytics telemetry.")
return

event_name = event_enum.value
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from unittest.mock import Mock, patch

import ai.feature_flags as feature_flags
from ai.api.tests.test_views import WisdomServiceAPITestCaseBase
from ai.api.utils import segment_analytics_telemetry
from ai.api.utils.analytics_telemetry_model import (
Expand Down Expand Up @@ -74,11 +75,13 @@ def _assert_segment_analytics_error_sent(self, error, send_segment_event):
error_event_payload, "analyticsTelemetryError", self.user
)

@patch("ai.api.utils.segment_analytics_telemetry.base_send_segment_event")
@override_settings(SEGMENT_ANALYTICS_WRITE_KEY="testWriteKey")
@override_settings(ADMIN_PORTAL_TELEMETRY_OPT_ENABLED=True)
def test_send_segment_analytics_event(self, base_send_segment_event):
analytics_event_object = AnalyticsProductFeedback("3", 123)
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
@patch("ai.api.utils.segment_analytics_telemetry.base_send_segment_event")
def test_send_segment_analytics_event(self, base_send_segment_event, LDClient):
LDClient.return_value.variation.return_value = True
analytics_event_object = AnalyticsProductFeedback(3, 123)
payload = Mock(return_value=analytics_event_object)
send_segment_analytics_event(AnalyticsTelemetryEvents.PRODUCT_FEEDBACK, payload, self.user)
payload.assert_called()
Expand All @@ -89,10 +92,12 @@ def test_send_segment_analytics_event(self, base_send_segment_event):
get_segment_analytics_client(),
)

@patch("ai.api.utils.segment_analytics_telemetry.send_segment_event")
@override_settings(SEGMENT_ANALYTICS_WRITE_KEY="testWriteKey")
@override_settings(ADMIN_PORTAL_TELEMETRY_OPT_ENABLED=True)
def test_send_segment_analytics_event_error_validation(self, send_segment_event):
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
@patch("ai.api.utils.segment_analytics_telemetry.send_segment_event")
def test_send_segment_analytics_event_error_validation(self, send_segment_event, LDClient):
LDClient.return_value.variation.return_value = True
payload = Mock(side_effect=ValueError)
send_segment_analytics_event(AnalyticsTelemetryEvents.PRODUCT_FEEDBACK, payload, self.user)
payload.assert_called()
Expand All @@ -107,42 +112,58 @@ def test_send_segment_analytics_event_error_validation(self, send_segment_event)
error_event_payload, "analyticsTelemetryError", self.user
)

@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
@patch("ai.api.utils.segment_analytics_telemetry.base_send_segment_event")
@override_settings(ADMIN_PORTAL_TELEMETRY_OPT_ENABLED=True)
def test_send_segment_analytics_event_error_not_write_key(self, base_send_segment_event):
def test_send_segment_analytics_event_error_not_write_key(
self, base_send_segment_event, LDClient
):
LDClient.return_value.variation.return_value = True
self._assert_event_not_sent(base_send_segment_event)

@patch("ai.api.utils.segment_analytics_telemetry.base_send_segment_event")
@override_settings(SEGMENT_ANALYTICS_WRITE_KEY="testWriteKey")
@override_settings(ADMIN_PORTAL_TELEMETRY_OPT_ENABLED=True)
def test_send_segment_analytics_event_error_user_no_seat(self, base_send_segment_event):
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
@patch("ai.api.utils.segment_analytics_telemetry.base_send_segment_event")
def test_send_segment_analytics_event_error_user_no_seat(
self, base_send_segment_event, LDClient
):
LDClient.return_value.variation.return_value = True
self.user.rh_user_has_seat = False
self._assert_event_not_sent(base_send_segment_event)

@patch("ai.api.utils.segment_analytics_telemetry.base_send_segment_event")
@override_settings(SEGMENT_ANALYTICS_WRITE_KEY="testWriteKey")
@override_settings(ADMIN_PORTAL_TELEMETRY_OPT_ENABLED=False)
def test_send_segment_analytics_event_error_no_telemetry_enabled(self, base_send_segment_event):
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
@patch("ai.api.utils.segment_analytics_telemetry.base_send_segment_event")
def test_send_segment_analytics_event_error_no_telemetry_enabled(
self, base_send_segment_event, LDClient
):
LDClient.return_value.variation.return_value = False
self._assert_event_not_sent(base_send_segment_event)

@patch("ai.api.utils.segment_analytics_telemetry.base_send_segment_event")
@override_settings(SEGMENT_ANALYTICS_WRITE_KEY="testWriteKey")
@override_settings(ADMIN_PORTAL_TELEMETRY_OPT_ENABLED=True)
def test_send_segment_analytics_event_error_no_org(self, base_send_segment_event):
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
@patch("ai.api.utils.segment_analytics_telemetry.base_send_segment_event")
def test_send_segment_analytics_event_error_no_org(self, base_send_segment_event, LDClient):
LDClient.return_value.variation.return_value = True
self.user.organization = None
self._assert_event_not_sent(base_send_segment_event)

@patch("ai.api.utils.segment_analytics_telemetry.base_send_segment_event")
@override_settings(SEGMENT_ANALYTICS_WRITE_KEY="testWriteKey")
@override_settings(ADMIN_PORTAL_TELEMETRY_OPT_ENABLED=True)
@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
@patch("ai.api.utils.segment_analytics_telemetry.base_send_segment_event")
def test_send_segment_analytics_event_error_no_org_telemetry_enabled(
self, base_send_segment_event
self, base_send_segment_event, LDClient
):
LDClient.return_value.variation.return_value = True
self.user.organization.telemetry_opt_out = True
self._assert_event_not_sent(base_send_segment_event)

def _assert_event_not_sent(self, base_send_segment_event):
payload = Mock(return_value=AnalyticsProductFeedback("3", 123))
payload = Mock(return_value=AnalyticsProductFeedback(3, 123))
send_segment_analytics_event(AnalyticsTelemetryEvents.PRODUCT_FEEDBACK, payload, self.user)
payload.assert_not_called()
base_send_segment_event.assert_not_called()
24 changes: 23 additions & 1 deletion ansible_wisdom/ai/feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,23 @@


class WisdomFlags(str, Enum):
MODEL_NAME = "model_name" # model name selection
# model name selection
MODEL_NAME = "model_name"
# Schema 2 Telemetry is enabled for an Organization
SCHEMA_2_TELEMETRY_ORG_ENABLED = "schema_2_telemetry_org_enabled"


class FeatureFlags:
instance = None

# Ensure FeatureFlags is a Singleton
def __new__(cls):
if cls.instance is not None:
return cls.instance
else:
inst = cls.instance = super().__new__(cls)
return inst

def __init__(self):
self.client = None
if settings.LAUNCHDARKLY_SDK_KEY:
Expand Down Expand Up @@ -61,3 +74,12 @@ def get(self, name: str, user: User, default: str):
return self.client.variation(name, user_context, default)
else:
raise Exception("feature flag client is not initialized")

def is_schema_2_telemetry_enabled(self, org_id: int) -> bool:
if self.client:
logger.debug(f"constructing User context for Organization '{org_id}'")
logger.debug(f"retrieving feature flag '{WisdomFlags.SCHEMA_2_TELEMETRY_ORG_ENABLED}'")
context = Context.builder(str(org_id)).set("org_id", org_id).build()
return self.client.variation(WisdomFlags.SCHEMA_2_TELEMETRY_ORG_ENABLED, context, False)
else:
raise Exception("feature flag client is not initialized")
27 changes: 27 additions & 0 deletions ansible_wisdom/ai/tests/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

import ai.feature_flags as feature_flags
from ai.api.tests.test_views import WisdomServiceAPITestCaseBase
from ai.feature_flags import WisdomFlags
from django.conf import settings
from django.test import override_settings
from ldclient import Context
from ldclient.config import Config


Expand Down Expand Up @@ -51,3 +53,28 @@ def test_feature_flags_with_local_file(self):
value = ff.get('model_name', self.user, 'default_value')
self.assertEqual(ff.client.get_sdk_key(), 'sdk-key-123abc')
self.assertEqual(value, 'dev_model')

@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
def test_feature_flags_is_schema_2_telemetry_disabled(self, LDClient):
LDClient.return_value.variation.return_value = False

ff = feature_flags.FeatureFlags()
self.assertFalse(ff.is_schema_2_telemetry_enabled(123))

@override_settings(LAUNCHDARKLY_SDK_KEY='dummy_key')
@patch.object(feature_flags, 'LDClient')
def test_feature_flags_is_schema_2_telemetry_enabled(self, LDClient):
LDClient.return_value.variation.return_value = True

ff = feature_flags.FeatureFlags()
self.assertTrue(ff.is_schema_2_telemetry_enabled(123))

args = LDClient.return_value.variation.call_args_list[0]
name: str = args[0][0]
context: Context = args[0][1]
self.assertEqual(name, WisdomFlags.SCHEMA_2_TELEMETRY_ORG_ENABLED)
self.assertEqual(context.kind, 'user')
self.assertEqual(context.key, '123')
self.assertEqual(context.custom_attributes['org_id'], 123)
self.assertFalse(args[0][2])
Loading

0 comments on commit 08ac0d5

Please sign in to comment.