diff --git a/ansible_ai_connect/main/settings/base.py b/ansible_ai_connect/main/settings/base.py index c2841188b..3d0566585 100644 --- a/ansible_ai_connect/main/settings/base.py +++ b/ansible_ai_connect/main/settings/base.py @@ -46,6 +46,9 @@ ANSIBLE_AI_CHATBOT_NAME = ( os.getenv("ANSIBLE_AI_CHATBOT_NAME") or "Ansible Lightspeed Intelligent Assistant" ) +ANSIBLE_AI_PROJECT_WCA_SUFFIX = ( + os.getenv("ANSIBLE_AI_PROJECT_WCA_SUFFIX") or " with IBM watsonx Code Assistant" +) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ diff --git a/ansible_ai_connect/main/tests/test_utils.py b/ansible_ai_connect/main/tests/test_utils.py new file mode 100644 index 000000000..cdb87cab2 --- /dev/null +++ b/ansible_ai_connect/main/tests/test_utils.py @@ -0,0 +1,287 @@ +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# from unittest.mock import patch + +# from django.test import RequestFactory, override_settings + +# from ansible_ai_connect.ai.api.model_pipelines.tests import mock_config +# from ansible_ai_connect.main.utils import ( +# get_project_name_with_wca_suffix, +# has_wca_providers, +# ) +# from ansible_ai_connect.test_utils import WisdomServiceLogAwareTestCase + +from unittest.mock import patch + +from django.test import RequestFactory + +from ansible_ai_connect.ai.api.model_pipelines.factory import ModelPipelineFactory +from ansible_ai_connect.ai.api.model_pipelines.tests import mock_config +from ansible_ai_connect.main.utils import ( + get_project_name_with_wca_suffix, + has_wca_providers, +) +from ansible_ai_connect.test_utils import WisdomServiceLogAwareTestCase + + +class TestHasWCAProvidersWithWCAProvider(WisdomServiceLogAwareTestCase): + def test_returns_true_with_wca_provider(self): + """Test that has_wca_providers returns True when WCA provider is configured.""" + # Create a factory with WCA config directly, without affecting global state + config_path = ( + "ansible_ai_connect.ai.api.model_pipelines.config_loader.settings." + "ANSIBLE_AI_MODEL_MESH_CONFIG" + ) + with patch(config_path, mock_config("wca")): + factory = ModelPipelineFactory() + with patch("ansible_ai_connect.main.utils.apps.get_app_config") as mock_app: + mock_app.return_value._pipeline_factory = factory + self.assertTrue(has_wca_providers()) + + +class TestHasWCAProvidersWithWCAOnPremProvider(WisdomServiceLogAwareTestCase): + def test_returns_true_with_wca_onprem_provider(self): + """Test that has_wca_providers returns True when WCA-onprem provider is configured.""" + config_path = ( + "ansible_ai_connect.ai.api.model_pipelines.config_loader.settings." + "ANSIBLE_AI_MODEL_MESH_CONFIG" + ) + with patch(config_path, mock_config("wca-onprem")): + factory = ModelPipelineFactory() + with patch("ansible_ai_connect.main.utils.apps.get_app_config") as mock_app: + mock_app.return_value._pipeline_factory = factory + self.assertTrue(has_wca_providers()) + + +class TestHasWCAProvidersWithHttpProvider(WisdomServiceLogAwareTestCase): + def test_returns_false_with_http_provider(self): + """Test that has_wca_providers returns False when HTTP provider is configured.""" + config_path = ( + "ansible_ai_connect.ai.api.model_pipelines.config_loader.settings." + "ANSIBLE_AI_MODEL_MESH_CONFIG" + ) + with patch(config_path, mock_config("http")): + factory = ModelPipelineFactory() + with patch("ansible_ai_connect.main.utils.apps.get_app_config") as mock_app: + mock_app.return_value._pipeline_factory = factory + self.assertFalse(has_wca_providers()) + + +class TestHasWCAProvidersWithEmptyConfig(WisdomServiceLogAwareTestCase): + def test_returns_false_with_empty_config(self): + """Test that has_wca_providers returns False with empty configuration.""" + config_path = ( + "ansible_ai_connect.ai.api.model_pipelines.config_loader.settings." + "ANSIBLE_AI_MODEL_MESH_CONFIG" + ) + with patch(config_path, "{}"): + factory = ModelPipelineFactory() + with patch("ansible_ai_connect.main.utils.apps.get_app_config") as mock_app: + mock_app.return_value._pipeline_factory = factory + self.assertFalse(has_wca_providers()) + + +class TestGetProjectNameWithWCASuffixWithRealWCAConfig(WisdomServiceLogAwareTestCase): + def test_integration_with_real_wca_config(self): + """Integration test using real configuration.""" + config_path = ( + "ansible_ai_connect.ai.api.model_pipelines.config_loader.settings." + "ANSIBLE_AI_MODEL_MESH_CONFIG" + ) + with patch(config_path, mock_config("wca")): + factory = ModelPipelineFactory() + with patch("ansible_ai_connect.main.utils.apps.get_app_config") as mock_app: + mock_app.return_value._pipeline_factory = factory + result = get_project_name_with_wca_suffix("Ansible Lightspeed") + self.assertEqual(result, "Ansible Lightspeed with IBM watsonx Code Assistant") + + +class TestGetProjectNameWithWCASuffixWithRealNonWCAConfig(WisdomServiceLogAwareTestCase): + def test_integration_with_real_non_wca_config(self): + """Integration test using real non-WCA configuration.""" + config_path = ( + "ansible_ai_connect.ai.api.model_pipelines.config_loader.settings." + "ANSIBLE_AI_MODEL_MESH_CONFIG" + ) + with patch(config_path, mock_config("dummy")): + factory = ModelPipelineFactory() + with patch("ansible_ai_connect.main.utils.apps.get_app_config") as mock_app: + mock_app.return_value._pipeline_factory = factory + result = get_project_name_with_wca_suffix("Ansible Lightspeed") + self.assertEqual(result, "Ansible Lightspeed") + + +class TestGetProjectNameWithWCASuffix(WisdomServiceLogAwareTestCase): + def test_adds_suffix_when_wca_providers_exist(self): + """Test that suffix is added when WCA providers are detected.""" + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix("Ansible Lightspeed") + self.assertEqual(result, "Ansible Lightspeed with IBM watsonx Code Assistant") + + def test_no_suffix_when_no_wca_providers(self): + """Test that no suffix is added when no WCA providers are detected.""" + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=False): + result = get_project_name_with_wca_suffix("Ansible Lightspeed") + self.assertEqual(result, "Ansible Lightspeed") + + def test_handles_empty_base_name(self): + """Test that function handles empty base project name.""" + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix("") + self.assertEqual(result, " with IBM watsonx Code Assistant") + + def test_handles_none_base_name(self): + """Test that function handles None as base project name.""" + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix(None) + self.assertEqual(result, "None with IBM watsonx Code Assistant") + + def test_preserves_base_name_when_no_wca(self): + """Test that base name is preserved exactly when no WCA providers.""" + test_names = [ + "Ansible AI Connect", + "Custom Project Name", + "Project with Special Characters !@#", + " Padded Name ", + ] + + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=False): + for base_name in test_names: + with self.subTest(base_name=base_name): + result = get_project_name_with_wca_suffix(base_name) + self.assertEqual(result, base_name) + + def test_idempotent_with_existing_suffix_and_wca_providers(self): + """Test function is idempotent when suffix exists and WCA providers present.""" + base_name = "Ansible Lightspeed with IBM watsonx Code Assistant" + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix(base_name) + self.assertEqual(result, "Ansible Lightspeed with IBM watsonx Code Assistant") + + def test_idempotent_with_existing_suffix_and_no_wca_providers(self): + """Test that function preserves existing suffix even when no WCA providers.""" + base_name = "Ansible Lightspeed with IBM watsonx Code Assistant" + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=False): + result = get_project_name_with_wca_suffix(base_name) + self.assertEqual(result, "Ansible Lightspeed with IBM watsonx Code Assistant") + + def test_handles_partial_suffix_match(self): + """Test that function doesn't get confused by partial suffix matches.""" + test_cases = [ + "Ansible with IBM", + "Project with IBM watsonx", + "Ansible IBM watsonx Code Assistant", + "with IBM watsonx Code Assistant prefix", + ] + + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + for base_name in test_cases: + with self.subTest(base_name=base_name): + result = get_project_name_with_wca_suffix(base_name) + expected = f"{base_name} with IBM watsonx Code Assistant" + self.assertEqual(result, expected) + + def test_case_sensitive_suffix_matching(self): + """Test that suffix matching is case sensitive.""" + test_cases = [ + "Ansible Lightspeed with ibm watsonx code assistant", # lowercase + "Ansible Lightspeed with IBM WATSONX CODE ASSISTANT", # uppercase + "Ansible Lightspeed With IBM Watsonx Code Assistant", # mixed case + ] + + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + for base_name in test_cases: + with self.subTest(base_name=base_name): + result = get_project_name_with_wca_suffix(base_name) + expected = f"{base_name} with IBM watsonx Code Assistant" + self.assertEqual(result, expected) + + def test_multiple_calls_are_idempotent(self): + """Test that calling the function multiple times produces the same result.""" + base_name = "Ansible Lightspeed" + + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + # First call + result1 = get_project_name_with_wca_suffix(base_name) + # Second call with result of first call + result2 = get_project_name_with_wca_suffix(result1) + # Third call with result of second call + result3 = get_project_name_with_wca_suffix(result2) + + expected = "Ansible Lightspeed with IBM watsonx Code Assistant" + self.assertEqual(result1, expected) + self.assertEqual(result2, expected) + self.assertEqual(result3, expected) + + def test_empty_string_with_existing_suffix_check(self): + """Test behavior with empty string (edge case for endswith check).""" + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix("") + self.assertEqual(result, " with IBM watsonx Code Assistant") + + def test_none_value_with_existing_suffix_check(self): + """Test behavior with None value (edge case for endswith check).""" + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix(None) + self.assertEqual(result, "None with IBM watsonx Code Assistant") + + def test_no_suffix_for_chatbot_route_with_wca_providers(self): + """Test that no suffix is added when next param starts with /chatbot.""" + request = RequestFactory().get("/login?next=/chatbot") + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix("Ansible Lightspeed", request) + self.assertEqual(result, "Ansible Lightspeed") + + def test_no_suffix_for_chatbot_route_with_path(self): + """Test that no suffix is added when next param is /chatbot/something.""" + request = RequestFactory().get("/login?next=/chatbot/") + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix("Ansible Lightspeed", request) + self.assertEqual(result, "Ansible Lightspeed") + + def test_suffix_added_for_non_chatbot_routes(self): + """Test that suffix is added for non-chatbot routes when WCA providers exist.""" + request = RequestFactory().get("/login?next=/home") + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix("Ansible Lightspeed", request) + self.assertEqual(result, "Ansible Lightspeed with IBM watsonx Code Assistant") + + def test_suffix_added_for_empty_next_param(self): + """Test that suffix is added when next param is empty.""" + request = RequestFactory().get("/login?next=") + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix("Ansible Lightspeed", request) + self.assertEqual(result, "Ansible Lightspeed with IBM watsonx Code Assistant") + + def test_suffix_added_when_no_next_param(self): + """Test that suffix is added when there is no next param.""" + request = RequestFactory().get("/login") + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix("Ansible Lightspeed", request) + self.assertEqual(result, "Ansible Lightspeed with IBM watsonx Code Assistant") + + def test_backward_compatibility_without_request(self): + """Test that function works without request parameter for backward compatibility.""" + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix("Ansible Lightspeed") + self.assertEqual(result, "Ansible Lightspeed with IBM watsonx Code Assistant") + + def test_chatbot_route_case_sensitivity(self): + """Test that chatbot route detection is case sensitive.""" + request = RequestFactory().get("/login?next=/CHATBOT") + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + result = get_project_name_with_wca_suffix("Ansible Lightspeed", request) + # Should add suffix because /CHATBOT != /chatbot + self.assertEqual(result, "Ansible Lightspeed with IBM watsonx Code Assistant") diff --git a/ansible_ai_connect/main/tests/test_views.py b/ansible_ai_connect/main/tests/test_views.py index b51bc49d7..efd0f7749 100644 --- a/ansible_ai_connect/main/tests/test_views.py +++ b/ansible_ai_connect/main/tests/test_views.py @@ -151,6 +151,66 @@ class MockUser: self.assertEqual(response.url, "/") +@override_settings(ANSIBLE_AI_PROJECT_NAME="Ansible Lightspeed") +class LoginViewProjectNameTest(TestCase): + def test_project_name_with_wca_provider(self): + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + request = RequestFactory().get("/login") + request.user = AnonymousUser() + response = LoginView.as_view()(request) + response.render() + contents = response.content.decode() + self.assertIn("Log in to Ansible Lightspeed with IBM watsonx Code Assistant", contents) + + def test_project_name_without_wca_provider(self): + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=False): + request = RequestFactory().get("/login") + request.user = AnonymousUser() + response = LoginView.as_view()(request) + response.render() + contents = response.content.decode() + self.assertIn("Log in to Ansible Lightspeed", contents) + self.assertNotIn( + "Log in to Ansible Lightspeed with IBM watsonx Code Assistant", contents + ) + + def test_project_name_no_suffix_for_chatbot_redirect(self): + """Test that WCA suffix is not added when redirecting to chatbot route.""" + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + request = RequestFactory().get("/login?next=/chatbot") + request.user = AnonymousUser() + response = LoginView.as_view()(request) + response.render() + contents = response.content.decode() + self.assertIn("Log in to Ansible Lightspeed", contents) + self.assertNotIn( + "Log in to Ansible Lightspeed with IBM watsonx Code Assistant", contents + ) + + def test_project_name_no_suffix_for_chatbot_subpath_redirect(self): + """Test that WCA suffix is not added when redirecting to chatbot subpath.""" + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + request = RequestFactory().get("/login?next=/chatbot/") + request.user = AnonymousUser() + response = LoginView.as_view()(request) + response.render() + contents = response.content.decode() + self.assertIn("Log in to Ansible Lightspeed", contents) + self.assertNotIn( + "Log in to Ansible Lightspeed with IBM watsonx Code Assistant", contents + ) + + def test_project_name_with_suffix_for_non_chatbot_redirect(self): + """Test that WCA suffix is added when redirecting to non-chatbot route.""" + with patch("ansible_ai_connect.main.utils.has_wca_providers", return_value=True): + request = RequestFactory().get("/login?next=/home") + request.user = AnonymousUser() + response = LoginView.as_view()(request) + response.render() + contents = response.content.decode() + self.assertIn("Log in to Ansible Lightspeed with IBM watsonx Code Assistant", contents) + + @override_settings(ALLOW_METRICS_FOR_ANONYMOUS_USERS=False) class TestMetricsView(APITransactionTestCase): diff --git a/ansible_ai_connect/main/utils.py b/ansible_ai_connect/main/utils.py new file mode 100644 index 000000000..7f3ad5c36 --- /dev/null +++ b/ansible_ai_connect/main/utils.py @@ -0,0 +1,69 @@ +# Copyright Red Hat +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from django.apps import apps +from django.conf import settings + +logger = logging.getLogger(__name__) + + +def has_wca_providers() -> bool: + """Check if any ModelPipeline is configured to use WCA or WCA-onprem providers.""" + try: + ai_app = apps.get_app_config("ai") + factory = ai_app._pipeline_factory + if not factory or not factory.pipelines_config: + return False + + # Check all pipeline configurations for WCA providers + for pipeline_name, pipeline_config in factory.pipelines_config.items(): + if hasattr(pipeline_config, "provider") and pipeline_config.provider in [ + "wca", + "wca-onprem", + ]: + return True + return False + except Exception: + # If there's any error accessing the configuration, default to False + logger.exception("Error checking for WCA providers") + return False + + +def get_project_name_with_wca_suffix(base_project_name: str, request=None) -> str: + """Get project name with WCA suffix if WCA providers are configured. + + Args: + base_project_name: The base project name + request: Django request object (optional) - used to check for chatbot routes + + Returns: + Project name with or without WCA suffix based on configuration and route + """ + wca_suffix = settings.ANSIBLE_AI_PROJECT_WCA_SUFFIX + + # If the name already ends with the suffix, return as-is + if base_project_name and base_project_name.endswith(wca_suffix): + return base_project_name + + # Check if this is a chatbot route redirect - don't add suffix for chatbot routes + if request and request.GET.get("next", "").startswith("/chatbot"): + return base_project_name + + # Add suffix only if WCA providers are configured + if has_wca_providers(): + return f"{base_project_name}{wca_suffix}" + + return base_project_name diff --git a/ansible_ai_connect/main/views.py b/ansible_ai_connect/main/views.py index 301140aad..99ce038cd 100644 --- a/ansible_ai_connect/main/views.py +++ b/ansible_ai_connect/main/views.py @@ -44,6 +44,7 @@ from ansible_ai_connect.main.base_views import ProtectedTemplateView from ansible_ai_connect.main.permissions import IsAAPUser, IsRHInternalUser, IsTestUser from ansible_ai_connect.main.settings.base import SOCIAL_AUTH_OIDC_KEY +from ansible_ai_connect.main.utils import get_project_name_with_wca_suffix logger = logging.getLogger(__name__) @@ -53,7 +54,9 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["next"] = self.request.GET.get("next") or "/" context["deployment_mode"] = settings.DEPLOYMENT_MODE - context["project_name"] = settings.ANSIBLE_AI_PROJECT_NAME + context["project_name"] = get_project_name_with_wca_suffix( + settings.ANSIBLE_AI_PROJECT_NAME, self.request + ) context["aap_api_provider_name"] = settings.AAP_API_PROVIDER_NAME context["documentation_url"] = settings.COMMERCIAL_DOCUMENTATION_URL return context @@ -114,7 +117,9 @@ def get_template_names(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["project_name"] = settings.ANSIBLE_AI_PROJECT_NAME + context["project_name"] = get_project_name_with_wca_suffix( + settings.ANSIBLE_AI_PROJECT_NAME, self.request + ) user = self.request.user if user: context["user_name"] = user.username diff --git a/ansible_ai_connect/users/views.py b/ansible_ai_connect/users/views.py index 39757ea64..22840c4a2 100644 --- a/ansible_ai_connect/users/views.py +++ b/ansible_ai_connect/users/views.py @@ -38,6 +38,7 @@ from ansible_ai_connect.ai.api.telemetry import schema2_utils as schema2 from ansible_ai_connect.ai.api.utils.segment import send_schema1_event from ansible_ai_connect.main.cache.cache_per_user import cache_per_user +from ansible_ai_connect.main.utils import get_project_name_with_wca_suffix from ansible_ai_connect.users.constants import TRIAL_PLAN_NAME from ansible_ai_connect.users.models import Plan from ansible_ai_connect.users.one_click_trial import OneClickTrial @@ -106,7 +107,9 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["use_tech_preview"] = settings.ANSIBLE_AI_ENABLE_TECH_PREVIEW context["deployment_mode"] = settings.DEPLOYMENT_MODE - context["project_name"] = settings.ANSIBLE_AI_PROJECT_NAME + context["project_name"] = get_project_name_with_wca_suffix( + settings.ANSIBLE_AI_PROJECT_NAME, self.request + ) context["org_has_api_key"] = self.org_has_api_key context["is_auth_configured"] = self.is_auth_configured @@ -222,7 +225,9 @@ def get_context_data(self, **kwargs): has_active_plan, has_expired_plan, days_left = one_click_trial.get_plans() context["one_click_trial_available"] = one_click_trial.is_available() - context["project_name"] = settings.ANSIBLE_AI_PROJECT_NAME + context["project_name"] = get_project_name_with_wca_suffix( + settings.ANSIBLE_AI_PROJECT_NAME, self.request + ) context["deployment_mode"] = settings.DEPLOYMENT_MODE context["has_active_plan"] = has_active_plan context["days_left"] = days_left diff --git a/tools/docker-compose/compose.yaml b/tools/docker-compose/compose.yaml index cef83f8bf..30cb93401 100644 --- a/tools/docker-compose/compose.yaml +++ b/tools/docker-compose/compose.yaml @@ -79,6 +79,8 @@ services: - CHATBOT_DEFAULT_SYSTEM_PROMPT=${CHATBOT_DEFAULT_SYSTEM_PROMPT} - ANSIBLE_AI_MODEL_MESH_CONFIG=${ANSIBLE_AI_MODEL_MESH_CONFIG} - ANSIBLE_AI_ENABLE_ROLE_GEN_ENDPOINT=${ANSIBLE_AI_ENABLE_ROLE_GEN_ENDPOINT} + - ANSIBLE_AI_PROJECT_NAME=${ANSIBLE_AI_PROJECT_NAME} + - ANSIBLE_AI_PROJECT_WCA_SUFFIX=${ANSIBLE_AI_PROJECT_WCA_SUFFIX} - AAP_API_URL=${AAP_API_URL} - SOCIAL_AUTH_AAP_KEY=${SOCIAL_AUTH_AAP_KEY} - SOCIAL_AUTH_AAP_SECRET=${SOCIAL_AUTH_AAP_SECRET}