From 1cb2795aaa11351ed4767ae72df6daf7a685518d Mon Sep 17 00:00:00 2001 From: Djebran Lezzoum Date: Fri, 17 Jan 2025 13:24:11 +0100 Subject: [PATCH] API versioning strategy The intention of this PR is to build a concrete strategy to follow when versioning the project API. issue: https://issues.redhat.com/browse/AAP-37993 --- ansible_ai_connect/ai/api/base/__init__.py | 0 ansible_ai_connect/ai/api/base/saas.py | 38 ++++ ansible_ai_connect/ai/api/exceptions.py | 6 + .../test_api_telemetry_settings_views.py | 5 +- .../ai/api/tests/test_api_timeout.py | 2 +- .../ai/api/tests/test_permissions.py | 2 +- ansible_ai_connect/ai/api/tests/test_views.py | 2 +- ansible_ai_connect/ai/api/urls.py | 24 +-- ansible_ai_connect/ai/api/utils/version.py | 41 +++++ ansible_ai_connect/ai/api/versions/README.md | 31 ++++ .../ai/api/versions/__init__.py | 0 .../ai/api/versions/v0/__init__.py | 0 .../ai/api/versions/v0/ai/__init__.py | 0 .../ai/api/versions/v0/ai/urls.py | 28 +++ .../ai/api/versions/v0/ai/views.py | 33 ++++ .../ai/api/versions/v0/telemetry/__init__.py | 0 .../ai/api/versions/v0/telemetry/urls.py | 21 +++ .../ai/api/versions/v0/telemetry/views.py | 19 ++ ansible_ai_connect/ai/api/versions/v0/urls.py | 32 ++++ .../ai/api/versions/v0/users/__init__.py | 0 .../ai/api/versions/v0/users/urls.py | 22 +++ .../ai/api/versions/v0/users/views.py | 17 ++ .../ai/api/versions/v0/wca/__init__.py | 0 .../ai/api/versions/v0/wca/urls.py | 24 +++ .../{wca/urls.py => versions/v0/wca/views.py} | 11 +- .../ai/api/versions/v1/__init__.py | 0 .../ai/api/versions/v1/ai/__init__.py | 0 .../ai/api/versions/v1/ai/urls.py | 27 +++ .../ai/api/versions/v1/ai/views.py | 33 ++++ .../ai/api/versions/v1/telemetry/__init__.py | 0 .../api/versions/v1/telemetry/serializers.py | 17 ++ .../ai/api/versions/v1/telemetry/urls.py | 21 +++ .../ai/api/versions/v1/telemetry/views.py | 61 +++++++ ansible_ai_connect/ai/api/versions/v1/urls.py | 27 +++ .../ai/api/versions/v1/users/__init__.py | 0 .../ai/api/versions/v1/users/urls.py | 22 +++ .../ai/api/versions/v1/users/views.py | 17 ++ .../ai/api/versions/v1/wca/__init__.py | 0 .../ai/api/versions/v1/wca/serializers.py | 20 +++ .../ai/api/versions/v1/wca/urls.py | 24 +++ .../ai/api/versions/v1/wca/views.py | 165 ++++++++++++++++++ .../ai/api/wca/tests/test_api_key_views.py | 3 +- .../ai/api/wca/tests/test_model_id_views.py | 3 +- ansible_ai_connect/main/middleware.py | 12 +- ansible_ai_connect/main/settings/base.py | 4 + .../main/tests/test_middleware.py | 18 +- .../main/tests/test_permissions.py | 11 +- ansible_ai_connect/main/tests/test_views.py | 7 +- ansible_ai_connect/main/urls.py | 28 +-- ansible_ai_connect/users/tests/test_users.py | 27 +-- .../ansible-ai-connect-service.yaml | 2 +- 51 files changed, 817 insertions(+), 90 deletions(-) create mode 100644 ansible_ai_connect/ai/api/base/__init__.py create mode 100644 ansible_ai_connect/ai/api/base/saas.py create mode 100644 ansible_ai_connect/ai/api/utils/version.py create mode 100644 ansible_ai_connect/ai/api/versions/README.md create mode 100644 ansible_ai_connect/ai/api/versions/__init__.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/__init__.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/ai/__init__.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/ai/urls.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/ai/views.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/telemetry/__init__.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/telemetry/urls.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/telemetry/views.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/urls.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/users/__init__.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/users/urls.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/users/views.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/wca/__init__.py create mode 100644 ansible_ai_connect/ai/api/versions/v0/wca/urls.py rename ansible_ai_connect/ai/api/{wca/urls.py => versions/v0/wca/views.py} (68%) create mode 100644 ansible_ai_connect/ai/api/versions/v1/__init__.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/ai/__init__.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/ai/urls.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/ai/views.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/telemetry/__init__.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/telemetry/serializers.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/telemetry/urls.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/telemetry/views.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/urls.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/users/__init__.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/users/urls.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/users/views.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/wca/__init__.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/wca/serializers.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/wca/urls.py create mode 100644 ansible_ai_connect/ai/api/versions/v1/wca/views.py diff --git a/ansible_ai_connect/ai/api/base/__init__.py b/ansible_ai_connect/ai/api/base/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_ai_connect/ai/api/base/saas.py b/ansible_ai_connect/ai/api/base/saas.py new file mode 100644 index 000000000..f3e72ddcf --- /dev/null +++ b/ansible_ai_connect/ai/api/base/saas.py @@ -0,0 +1,38 @@ +# 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 django.conf import settings + +from ansible_ai_connect.ai.api import exceptions + + +class APIViewSaasMixin: + + def initial(self, request, *args, **kwargs): + self.check_saas_settings(request) + return super().initial(request, *args, **kwargs) + + def check_saas_settings(self, request): + """call the correct error function when DEPLOYMENT_MODE is not saas + and not in DEBUG mode""" + if not settings.DEBUG and settings.DEPLOYMENT_MODE != "saas": + self.not_implemented_error( + request, message="This functionality is not available in the current environment" + ) + + def not_implemented_error(self, request, message=None, code=None): + """ + raise not implement exception when called + """ + raise exceptions.NotImplementedException(detail=message, code=code) diff --git a/ansible_ai_connect/ai/api/exceptions.py b/ansible_ai_connect/ai/api/exceptions.py index 54b607a94..7a4ebc34c 100644 --- a/ansible_ai_connect/ai/api/exceptions.py +++ b/ansible_ai_connect/ai/api/exceptions.py @@ -182,6 +182,12 @@ class InternalServerError(BaseWisdomAPIException): default_detail = "An error occurred attempting to complete the request." +class NotImplementedException(BaseWisdomAPIException): + status_code = 501 + default_code = "not_implemented" + default_detail = "This functionality is not implemented." + + class FeedbackValidationException(WisdomBadRequest): default_code = "error__feedback_validation" diff --git a/ansible_ai_connect/ai/api/telemetry/tests/test_api_telemetry_settings_views.py b/ansible_ai_connect/ai/api/telemetry/tests/test_api_telemetry_settings_views.py index a973f72a8..02e124ce4 100644 --- a/ansible_ai_connect/ai/api/telemetry/tests/test_api_telemetry_settings_views.py +++ b/ansible_ai_connect/ai/api/telemetry/tests/test_api_telemetry_settings_views.py @@ -17,7 +17,7 @@ from django.db.utils import DatabaseError from django.test import override_settings -from django.urls import resolve, reverse +from django.urls import resolve from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope from rest_framework.permissions import IsAuthenticated @@ -27,6 +27,7 @@ IsOrganisationLightspeedSubscriber, ) from ansible_ai_connect.ai.api.tests.test_views import WisdomServiceAPITestCaseBase +from ansible_ai_connect.ai.api.utils.version import api_version_reverse as reverse from ansible_ai_connect.organizations.models import Organization @@ -49,6 +50,7 @@ def test_permission_classes(self, *args): required_permissions = [ IsAuthenticated, IsAuthenticatedOrTokenHasScope, + IsAuthenticatedOrTokenHasScope, IsOrganisationAdministrator, IsOrganisationLightspeedSubscriber, ] @@ -116,7 +118,6 @@ 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) - # Settings should initially be False r = self.client.get(reverse("telemetry_settings")) self.assertEqual(r.status_code, HTTPStatus.OK) diff --git a/ansible_ai_connect/ai/api/tests/test_api_timeout.py b/ansible_ai_connect/ai/api/tests/test_api_timeout.py index e2d39a503..3319c45dd 100644 --- a/ansible_ai_connect/ai/api/tests/test_api_timeout.py +++ b/ansible_ai_connect/ai/api/tests/test_api_timeout.py @@ -19,7 +19,6 @@ import grpc from django.apps import apps from django.test import override_settings -from django.urls import reverse from requests.exceptions import ReadTimeout from ansible_ai_connect.ai.api.exceptions import ModelTimeoutException @@ -34,6 +33,7 @@ from ansible_ai_connect.ai.api.model_pipelines.wca.pipelines_saas import ( WCASaaSCompletionsPipeline, ) +from ansible_ai_connect.ai.api.utils.version import api_version_reverse as reverse from ..model_pipelines.tests import mock_pipeline_config from .test_views import WisdomServiceAPITestCaseBase diff --git a/ansible_ai_connect/ai/api/tests/test_permissions.py b/ansible_ai_connect/ai/api/tests/test_permissions.py index b3328e713..55aa9bd5e 100644 --- a/ansible_ai_connect/ai/api/tests/test_permissions.py +++ b/ansible_ai_connect/ai/api/tests/test_permissions.py @@ -16,7 +16,6 @@ from unittest.mock import Mock, patch from django.test import override_settings -from django.urls import reverse from ansible_ai_connect.ai.api.permissions import ( BlockUserWithoutSeat, @@ -28,6 +27,7 @@ IsWCASaaSModelPipeline, ) from ansible_ai_connect.ai.api.tests.test_views import WisdomServiceAPITestCaseBase +from ansible_ai_connect.ai.api.utils.version import api_version_reverse as reverse from ansible_ai_connect.test_utils import WisdomAppsBackendMocking from ansible_ai_connect.users.models import Plan from ansible_ai_connect.users.tests.test_users import create_user diff --git a/ansible_ai_connect/ai/api/tests/test_views.py b/ansible_ai_connect/ai/api/tests/test_views.py index 56a02c5dc..b500e285e 100644 --- a/ansible_ai_connect/ai/api/tests/test_views.py +++ b/ansible_ai_connect/ai/api/tests/test_views.py @@ -29,7 +29,6 @@ from django.apps import apps from django.contrib.auth import get_user_model from django.test import modify_settings, override_settings -from django.urls import reverse from langchain_core.runnables import Runnable, RunnableConfig from langchain_core.runnables.utils import Input, Output from requests.exceptions import ReadTimeout @@ -123,6 +122,7 @@ CompletionsPromptType, ) from ansible_ai_connect.ai.api.serializers import CompletionRequestSerializer +from ansible_ai_connect.ai.api.utils.version import api_version_reverse as reverse from ansible_ai_connect.healthcheck.backends import HealthCheckSummary from ansible_ai_connect.main.tests.test_views import create_user_with_provider from ansible_ai_connect.organizations.models import Organization diff --git a/ansible_ai_connect/ai/api/urls.py b/ansible_ai_connect/ai/api/urls.py index 472d4a073..9a07c0383 100644 --- a/ansible_ai_connect/ai/api/urls.py +++ b/ansible_ai_connect/ai/api/urls.py @@ -12,26 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from django.urls import path +from django.urls import include, path -from .views import ( - Chat, - Completions, - ContentMatches, - Explanation, - Feedback, - GenerationPlaybook, - GenerationRole, -) +from .versions.v0 import urls as v0_urls +from .versions.v1 import urls as v1_urls urlpatterns = [ - path("completions/", Completions.as_view(), name="completions"), - path("contentmatches/", ContentMatches.as_view(), name="contentmatches"), - path("explanations/", Explanation.as_view(), name="explanations"), - # Legacy - path("generations/", GenerationPlaybook.as_view(), name="generations"), - path("generations/playbook", GenerationPlaybook.as_view(), name="generations/playbook"), - path("generations/role", GenerationRole.as_view(), name="generations/role"), - path("feedback/", Feedback.as_view(), name="feedback"), - path("chat/", Chat.as_view(), name="chat"), + path("v0/", include((v0_urls, "ai"), namespace="v0")), + path("v1/", include((v1_urls, "ai"), namespace="v1")), ] diff --git a/ansible_ai_connect/ai/api/utils/version.py b/ansible_ai_connect/ai/api/utils/version.py new file mode 100644 index 000000000..e4aa34e9b --- /dev/null +++ b/ansible_ai_connect/ai/api/utils/version.py @@ -0,0 +1,41 @@ +# 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 django.conf import settings +from django.urls import reverse + + +def get_api_version_view_name(view_name: str, api_version: str | None = None) -> str: + """ + Get the view name with version, if no version supplied, use REST FRAMEWORK + default configured version + :param view_name: the registered view + :param api_version: the version requested the view name is registered + :return: view name of the version + """ + if not api_version: + api_version = getattr(settings, "REST_FRAMEWORK", {}).get("DEFAULT_VERSION") + if api_version: + view_name = f"{api_version}:{view_name}" + return view_name + + +def api_version_reverse(view_name: str, api_version: str | None = None, **kwargs) -> str: + """ + Return the django reverse of a versioned view name + :param view_name: the registered view + :param api_version: the version requested the view name is registered + :param kwargs: other django reverse kwargs + """ + return reverse(get_api_version_view_name(view_name, api_version=api_version), **kwargs) diff --git a/ansible_ai_connect/ai/api/versions/README.md b/ansible_ai_connect/ai/api/versions/README.md new file mode 100644 index 000000000..8a1bdea6d --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/README.md @@ -0,0 +1,31 @@ + +## Versioning principles and restrictions: + +The application API is organized by versions directory structure, that rely on some basic principles and restrictions: + +- The views and serializers outside the versions directories is considered as base code, +in the future the related modules should be moved to a base directory. + +- urls modules use views that are imported only from the same version directory hierarchy. +- urls modules use urls to include that are imported only from the same version directory hierarchy, +an exception is made for urls to include from external application. + +- views modules can use serializers that are imported only from the same version directory hierarchy. + +views modules of a version x can: +- reuse the base views, directly or modified (using inheritance). +- reuse the views only from the previous x-1 version, directly or modified (using inheritance). + +serializers modules of a version x can: +- reuse the base serializers, directly or modified (using inheritance). +- reuse the serializers only from the previous x-1 version, directly or modified (using inheritance). + +## Generate a concrete API schema version + +```commandline +VERSION="v1" podman exec -it --user=0 docker-compose-django-1 wisdom-manage spectacular --api-version $VERSION --file /var/www/ansible-ai-connect-service/ansible_ai_connect/schema-$VERSION.yaml +``` +This will generate a schema file with requested version at ansible_ai_connect directory + +the default format is openapi (yaml file format) +but also openapi-json can be produced with "--format openapi-json" option diff --git a/ansible_ai_connect/ai/api/versions/__init__.py b/ansible_ai_connect/ai/api/versions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_ai_connect/ai/api/versions/v0/__init__.py b/ansible_ai_connect/ai/api/versions/v0/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_ai_connect/ai/api/versions/v0/ai/__init__.py b/ansible_ai_connect/ai/api/versions/v0/ai/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_ai_connect/ai/api/versions/v0/ai/urls.py b/ansible_ai_connect/ai/api/versions/v0/ai/urls.py new file mode 100644 index 000000000..d1afa5435 --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v0/ai/urls.py @@ -0,0 +1,28 @@ +# 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 django.urls import path + +from . import views + +urlpatterns = [ + path("completions/", views.Completions.as_view(), name="completions"), + path("contentmatches/", views.ContentMatches.as_view(), name="contentmatches"), + path("explanations/", views.Explanation.as_view(), name="explanations"), + path("generations/", views.GenerationPlaybook.as_view(), name="generations"), + path("generations/playbook", views.GenerationPlaybook.as_view(), name="generations/playbook"), + path("generations/role", views.GenerationRole.as_view(), name="generations/role"), + path("feedback/", views.Feedback.as_view(), name="feedback"), + path("chat/", views.Chat.as_view(), name="chat"), +] diff --git a/ansible_ai_connect/ai/api/versions/v0/ai/views.py b/ansible_ai_connect/ai/api/versions/v0/ai/views.py new file mode 100644 index 000000000..25c2146a9 --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v0/ai/views.py @@ -0,0 +1,33 @@ +# 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 ansible_ai_connect.ai.api.views import ( + Chat, + Completions, + ContentMatches, + Explanation, + Feedback, + GenerationPlaybook, + GenerationRole, +) + +__all__ = [ + Completions, + ContentMatches, + Explanation, + GenerationPlaybook, + GenerationRole, + Feedback, + Chat, +] diff --git a/ansible_ai_connect/ai/api/versions/v0/telemetry/__init__.py b/ansible_ai_connect/ai/api/versions/v0/telemetry/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_ai_connect/ai/api/versions/v0/telemetry/urls.py b/ansible_ai_connect/ai/api/versions/v0/telemetry/urls.py new file mode 100644 index 000000000..06645b268 --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v0/telemetry/urls.py @@ -0,0 +1,21 @@ +# 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 django.urls import path + +from . import views + +urlpatterns = [ + path("", views.TelemetrySettingsView.as_view(), name="telemetry_settings"), +] diff --git a/ansible_ai_connect/ai/api/versions/v0/telemetry/views.py b/ansible_ai_connect/ai/api/versions/v0/telemetry/views.py new file mode 100644 index 000000000..9cb934749 --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v0/telemetry/views.py @@ -0,0 +1,19 @@ +# 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 ansible_ai_connect.ai.api.telemetry.api_telemetry_settings_views import ( + TelemetrySettingsView, +) + +__all__ = [TelemetrySettingsView] diff --git a/ansible_ai_connect/ai/api/versions/v0/urls.py b/ansible_ai_connect/ai/api/versions/v0/urls.py new file mode 100644 index 000000000..1b3e9efaf --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v0/urls.py @@ -0,0 +1,32 @@ +# 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 django.conf import settings +from django.urls import include, path + +from .ai import urls as ai_urls +from .telemetry import urls as telemetry_urls +from .users import urls as me_urls +from .wca import urls as wca_urls + +urlpatterns = [ + path("ai/", include(ai_urls)), + path("me/", include(me_urls)), +] + +if settings.DEBUG or settings.DEPLOYMENT_MODE == "saas": + urlpatterns += [ + path("telemetry/", include(telemetry_urls)), + path("wca/", include(wca_urls)), + ] diff --git a/ansible_ai_connect/ai/api/versions/v0/users/__init__.py b/ansible_ai_connect/ai/api/versions/v0/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_ai_connect/ai/api/versions/v0/users/urls.py b/ansible_ai_connect/ai/api/versions/v0/users/urls.py new file mode 100644 index 000000000..5dafaacd0 --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v0/users/urls.py @@ -0,0 +1,22 @@ +# 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 django.urls import path + +from . import views + +urlpatterns = [ + path("", views.CurrentUserView.as_view(), name="me"), + path("summary/", views.MarkdownCurrentUserView.as_view(), name="me_summary"), +] diff --git a/ansible_ai_connect/ai/api/versions/v0/users/views.py b/ansible_ai_connect/ai/api/versions/v0/users/views.py new file mode 100644 index 000000000..d3420598a --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v0/users/views.py @@ -0,0 +1,17 @@ +# 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 ansible_ai_connect.users.views import CurrentUserView, MarkdownCurrentUserView + +__all__ = [CurrentUserView, MarkdownCurrentUserView] diff --git a/ansible_ai_connect/ai/api/versions/v0/wca/__init__.py b/ansible_ai_connect/ai/api/versions/v0/wca/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_ai_connect/ai/api/versions/v0/wca/urls.py b/ansible_ai_connect/ai/api/versions/v0/wca/urls.py new file mode 100644 index 000000000..c2feee15a --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v0/wca/urls.py @@ -0,0 +1,24 @@ +# 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 django.urls import path + +from . import views + +urlpatterns = [ + path("apikey/", views.WCAApiKeyView.as_view(), name="wca_api_key"), + path("modelid/", views.WCAModelIdView.as_view(), name="wca_model_id"), + path("apikey/test/", views.WCAApiKeyValidatorView.as_view(), name="wca_api_key_validator"), + path("modelid/test/", views.WCAModelIdValidatorView.as_view(), name="wca_model_id_validator"), +] diff --git a/ansible_ai_connect/ai/api/wca/urls.py b/ansible_ai_connect/ai/api/versions/v0/wca/views.py similarity index 68% rename from ansible_ai_connect/ai/api/wca/urls.py rename to ansible_ai_connect/ai/api/versions/v0/wca/views.py index e69aae667..503c4769e 100644 --- a/ansible_ai_connect/ai/api/wca/urls.py +++ b/ansible_ai_connect/ai/api/versions/v0/wca/views.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from django.urls import path from ansible_ai_connect.ai.api.wca.api_key_views import ( WCAApiKeyValidatorView, @@ -23,9 +22,9 @@ WCAModelIdView, ) -urlpatterns = [ - path("apikey/", WCAApiKeyView.as_view(), name="wca_api_key"), - path("modelid/", WCAModelIdView.as_view(), name="wca_model_id"), - path("apikey/test/", WCAApiKeyValidatorView.as_view(), name="wca_api_key_validator"), - path("modelid/test/", WCAModelIdValidatorView.as_view(), name="wca_model_id_validator"), +__all__ = [ + WCAApiKeyValidatorView, + WCAApiKeyView, + WCAModelIdValidatorView, + WCAModelIdView, ] diff --git a/ansible_ai_connect/ai/api/versions/v1/__init__.py b/ansible_ai_connect/ai/api/versions/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_ai_connect/ai/api/versions/v1/ai/__init__.py b/ansible_ai_connect/ai/api/versions/v1/ai/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_ai_connect/ai/api/versions/v1/ai/urls.py b/ansible_ai_connect/ai/api/versions/v1/ai/urls.py new file mode 100644 index 000000000..22d713289 --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v1/ai/urls.py @@ -0,0 +1,27 @@ +# 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 django.urls import path + +from . import views + +urlpatterns = [ + path("completions/", views.Completions.as_view(), name="completions"), + path("contentmatches/", views.ContentMatches.as_view(), name="contentmatches"), + path("explanations/", views.Explanation.as_view(), name="explanations"), + path("generations/playbook/", views.GenerationPlaybook.as_view(), name="generations/playbook"), + path("generations/role/", views.GenerationRole.as_view(), name="generations/role"), + path("feedback/", views.Feedback.as_view(), name="feedback"), + path("chat/", views.Chat.as_view(), name="chat"), +] diff --git a/ansible_ai_connect/ai/api/versions/v1/ai/views.py b/ansible_ai_connect/ai/api/versions/v1/ai/views.py new file mode 100644 index 000000000..25c2146a9 --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v1/ai/views.py @@ -0,0 +1,33 @@ +# 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 ansible_ai_connect.ai.api.views import ( + Chat, + Completions, + ContentMatches, + Explanation, + Feedback, + GenerationPlaybook, + GenerationRole, +) + +__all__ = [ + Completions, + ContentMatches, + Explanation, + GenerationPlaybook, + GenerationRole, + Feedback, + Chat, +] diff --git a/ansible_ai_connect/ai/api/versions/v1/telemetry/__init__.py b/ansible_ai_connect/ai/api/versions/v1/telemetry/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_ai_connect/ai/api/versions/v1/telemetry/serializers.py b/ansible_ai_connect/ai/api/versions/v1/telemetry/serializers.py new file mode 100644 index 000000000..bb06ad685 --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v1/telemetry/serializers.py @@ -0,0 +1,17 @@ +# 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 ansible_ai_connect.ai.api.serializers import TelemetrySettingsRequestSerializer + +__all__ = [TelemetrySettingsRequestSerializer] diff --git a/ansible_ai_connect/ai/api/versions/v1/telemetry/urls.py b/ansible_ai_connect/ai/api/versions/v1/telemetry/urls.py new file mode 100644 index 000000000..06645b268 --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v1/telemetry/urls.py @@ -0,0 +1,21 @@ +# 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 django.urls import path + +from . import views + +urlpatterns = [ + path("", views.TelemetrySettingsView.as_view(), name="telemetry_settings"), +] diff --git a/ansible_ai_connect/ai/api/versions/v1/telemetry/views.py b/ansible_ai_connect/ai/api/versions/v1/telemetry/views.py new file mode 100644 index 000000000..9aed01fef --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v1/telemetry/views.py @@ -0,0 +1,61 @@ +# 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 drf_spectacular.utils import OpenApiResponse, extend_schema + +from ansible_ai_connect.ai.api.base import saas +from ansible_ai_connect.ai.api.telemetry.api_telemetry_settings_views import ( + TelemetrySettingsView as _BaseTelemetrySettingsView, +) + +from . import serializers as serializers + + +class TelemetrySettingsView(saas.APIViewSaasMixin, _BaseTelemetrySettingsView): + + @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"), + 501: OpenApiResponse(description="Not implemented"), + }, + summary="Get the telemetry settings for an Organisation", + operation_id="telemetry_settings_get", + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + @extend_schema( + request=serializers.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"), + 501: OpenApiResponse(description="Not implemented"), + }, + summary="Set the Telemetry settings for an Organisation", + operation_id="telemetry_settings_set", + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + + +__all__ = [TelemetrySettingsView] diff --git a/ansible_ai_connect/ai/api/versions/v1/urls.py b/ansible_ai_connect/ai/api/versions/v1/urls.py new file mode 100644 index 000000000..9f6278cf8 --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v1/urls.py @@ -0,0 +1,27 @@ +# 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 django.urls import include, path + +from .ai import urls as ai_urls +from .telemetry import urls as telemetry_urls +from .users import urls as me_urls +from .wca import urls as wca_urls + +urlpatterns = [ + path("ai/", include(ai_urls)), + path("me/", include(me_urls)), + path("telemetry/", include(telemetry_urls)), + path("wca/", include(wca_urls)), +] diff --git a/ansible_ai_connect/ai/api/versions/v1/users/__init__.py b/ansible_ai_connect/ai/api/versions/v1/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_ai_connect/ai/api/versions/v1/users/urls.py b/ansible_ai_connect/ai/api/versions/v1/users/urls.py new file mode 100644 index 000000000..5dafaacd0 --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v1/users/urls.py @@ -0,0 +1,22 @@ +# 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 django.urls import path + +from . import views + +urlpatterns = [ + path("", views.CurrentUserView.as_view(), name="me"), + path("summary/", views.MarkdownCurrentUserView.as_view(), name="me_summary"), +] diff --git a/ansible_ai_connect/ai/api/versions/v1/users/views.py b/ansible_ai_connect/ai/api/versions/v1/users/views.py new file mode 100644 index 000000000..d3420598a --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v1/users/views.py @@ -0,0 +1,17 @@ +# 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 ansible_ai_connect.users.views import CurrentUserView, MarkdownCurrentUserView + +__all__ = [CurrentUserView, MarkdownCurrentUserView] diff --git a/ansible_ai_connect/ai/api/versions/v1/wca/__init__.py b/ansible_ai_connect/ai/api/versions/v1/wca/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ansible_ai_connect/ai/api/versions/v1/wca/serializers.py b/ansible_ai_connect/ai/api/versions/v1/wca/serializers.py new file mode 100644 index 000000000..382983b7e --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v1/wca/serializers.py @@ -0,0 +1,20 @@ +# 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 ansible_ai_connect.ai.api.serializers import ( + WcaKeyRequestSerializer, + WcaModelIdRequestSerializer, +) + +__all__ = [WcaKeyRequestSerializer, WcaModelIdRequestSerializer] diff --git a/ansible_ai_connect/ai/api/versions/v1/wca/urls.py b/ansible_ai_connect/ai/api/versions/v1/wca/urls.py new file mode 100644 index 000000000..c2feee15a --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v1/wca/urls.py @@ -0,0 +1,24 @@ +# 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 django.urls import path + +from . import views + +urlpatterns = [ + path("apikey/", views.WCAApiKeyView.as_view(), name="wca_api_key"), + path("modelid/", views.WCAModelIdView.as_view(), name="wca_model_id"), + path("apikey/test/", views.WCAApiKeyValidatorView.as_view(), name="wca_api_key_validator"), + path("modelid/test/", views.WCAModelIdValidatorView.as_view(), name="wca_model_id_validator"), +] diff --git a/ansible_ai_connect/ai/api/versions/v1/wca/views.py b/ansible_ai_connect/ai/api/versions/v1/wca/views.py new file mode 100644 index 000000000..cb06272f2 --- /dev/null +++ b/ansible_ai_connect/ai/api/versions/v1/wca/views.py @@ -0,0 +1,165 @@ +# 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 drf_spectacular.utils import OpenApiResponse, extend_schema + +from ansible_ai_connect.ai.api.base import saas +from ansible_ai_connect.ai.api.wca.api_key_views import ( + WCAApiKeyValidatorView as _BaseWCAApiKeyValidatorView, +) +from ansible_ai_connect.ai.api.wca.api_key_views import ( + WCAApiKeyView as _BaseWCAApiKeyView, +) +from ansible_ai_connect.ai.api.wca.model_id_views import ( + WCAModelIdValidatorView as _BaseWCAModelIdValidatorView, +) +from ansible_ai_connect.ai.api.wca.model_id_views import ( + WCAModelIdView as _BaseWCAModelIdView, +) + +from . import serializers + + +class WCAApiKeyView(saas.APIViewSaasMixin, _BaseWCAApiKeyView): + + @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"), + 501: OpenApiResponse(description="Not implemented"), + }, + summary="Get WCA key for an Organisation", + operation_id="wca_api_key_get", + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + @extend_schema( + request=serializers.WcaKeyRequestSerializer, + 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"), + 501: OpenApiResponse(description="Not implemented"), + }, + summary="Set the WCA key for an Organisation", + operation_id="wca_api_key_set", + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + + @extend_schema( + 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"), + 501: OpenApiResponse(description="Not implemented"), + }, + summary="DELETE WCA key for an Organization", + operation_id="wca_api_key_delete", + ) + def delete(self, request, *args, **kwargs): + super().delete(request, *args, **kwargs) + + +class WCAApiKeyValidatorView(saas.APIViewSaasMixin, _BaseWCAApiKeyValidatorView): + + @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"), + 501: OpenApiResponse(description="Not implemented"), + }, + summary="Validate WCA key for an Organisation", + operation_id="wca_api_key_validator_get", + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + +class WCAModelIdView(saas.APIViewSaasMixin, _BaseWCAModelIdView): + + @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"), + 501: OpenApiResponse(description="Not implemented"), + }, + summary="Get WCA Model Id for an Organisation", + operation_id="wca_model_id_get", + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + @extend_schema( + request=serializers.WcaModelIdRequestSerializer, + 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"), + 501: OpenApiResponse(description="Not implemented"), + }, + summary="Set the Model Id to be used for an Organisation", + operation_id="wca_model_id_set", + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + + +class WCAModelIdValidatorView(saas.APIViewSaasMixin, _BaseWCAModelIdValidatorView): + + @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"), + 501: OpenApiResponse(description="Not implemented"), + }, + summary="Validate WCA Model Id for an Organisation", + operation_id="wca_model_id_validator_get", + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + +__all__ = [ + WCAApiKeyValidatorView, + WCAApiKeyView, + WCAModelIdValidatorView, + WCAModelIdView, +] diff --git a/ansible_ai_connect/ai/api/wca/tests/test_api_key_views.py b/ansible_ai_connect/ai/api/wca/tests/test_api_key_views.py index 008fbbbc8..c185d8603 100644 --- a/ansible_ai_connect/ai/api/wca/tests/test_api_key_views.py +++ b/ansible_ai_connect/ai/api/wca/tests/test_api_key_views.py @@ -19,7 +19,7 @@ from django.apps import apps from django.test import override_settings -from django.urls import resolve, reverse +from django.urls import resolve from django.utils import timezone from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope from rest_framework.permissions import IsAuthenticated @@ -39,6 +39,7 @@ IsWCASaaSModelPipeline, ) from ansible_ai_connect.ai.api.tests.test_views import WisdomServiceAPITestCaseBase +from ansible_ai_connect.ai.api.utils.version import api_version_reverse as reverse from ansible_ai_connect.organizations.models import Organization from ansible_ai_connect.test_utils import WisdomAppsBackendMocking diff --git a/ansible_ai_connect/ai/api/wca/tests/test_model_id_views.py b/ansible_ai_connect/ai/api/wca/tests/test_model_id_views.py index 495043ee4..bbbc53b73 100644 --- a/ansible_ai_connect/ai/api/wca/tests/test_model_id_views.py +++ b/ansible_ai_connect/ai/api/wca/tests/test_model_id_views.py @@ -17,7 +17,7 @@ from django.apps import apps from django.test import override_settings -from django.urls import resolve, reverse +from django.urls import resolve from django.utils import timezone from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope from rest_framework.exceptions import ValidationError @@ -40,6 +40,7 @@ IsWCASaaSModelPipeline, ) from ansible_ai_connect.ai.api.tests.test_views import WisdomServiceAPITestCaseBase +from ansible_ai_connect.ai.api.utils.version import api_version_reverse as reverse from ansible_ai_connect.organizations.models import Organization from ansible_ai_connect.test_utils import WisdomAppsBackendMocking, WisdomLogAwareMixin diff --git a/ansible_ai_connect/main/middleware.py b/ansible_ai_connect/main/middleware.py index 8952e290e..bc71a9afc 100644 --- a/ansible_ai_connect/main/middleware.py +++ b/ansible_ai_connect/main/middleware.py @@ -20,7 +20,6 @@ from ansible_anonymizer import anonymizer from django.conf import settings from django.http import QueryDict -from django.urls import reverse from rest_framework.exceptions import ErrorDetail from segment import analytics from social_django.middleware import SocialAuthExceptionMiddleware @@ -35,6 +34,7 @@ from ansible_ai_connect.ai.api.utils.segment_analytics_telemetry import ( send_segment_analytics_event, ) +from ansible_ai_connect.ai.api.utils.version import api_version_reverse from ansible_ai_connect.healthcheck.version_info import VersionInfo logger = logging.getLogger(__name__) @@ -63,6 +63,12 @@ class SegmentMiddleware: def __init__(self, get_response): self.get_response = get_response + def _api_completions_paths(self): + return [ + api_version_reverse("completions", api_version=api_version) + for api_version in settings.REST_FRAMEWORK.get("ALLOWED_VERSIONS", []) + ] + def __call__(self, request): start_time = time.time() @@ -82,7 +88,7 @@ def __call__(self, request): # analytics.send = False # for code development only analytics.on_error = on_segment_error - if request.path == reverse("completions") and request.method == "POST": + if request.path in self._api_completions_paths() and request.method == "POST": if request.content_type == "application/json": try: request_data = ( @@ -97,7 +103,7 @@ def __call__(self, request): response = self.get_response(request) if settings.SEGMENT_WRITE_KEY: - if request.path == reverse("completions") and request.method == "POST": + if request.path in self._api_completions_paths() and request.method == "POST": request_suggestion_id = getattr( request, "_suggestion_id", request_data.get("suggestionId") ) diff --git a/ansible_ai_connect/main/settings/base.py b/ansible_ai_connect/main/settings/base.py index e477895f7..5d617a763 100644 --- a/ansible_ai_connect/main/settings/base.py +++ b/ansible_ai_connect/main/settings/base.py @@ -268,6 +268,10 @@ def is_ssl_enabled(value: str) -> bool: "EXCEPTION_HANDLER": "ansible_ai_connect.main.exception_handler." "exception_handler_with_error_type", "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), + "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", + "ALLOWED_VERSIONS": ("v0", "v1"), + "DEFAULT_VERSION": "v0", + "VERSION_PARAM": "version", } # Current RHSSOAuthentication implementation is incompatible with tech preview terms partial diff --git a/ansible_ai_connect/main/tests/test_middleware.py b/ansible_ai_connect/main/tests/test_middleware.py index b60685bb8..b0f1927ae 100644 --- a/ansible_ai_connect/main/tests/test_middleware.py +++ b/ansible_ai_connect/main/tests/test_middleware.py @@ -20,13 +20,13 @@ from django.apps import apps from django.test import override_settings -from django.urls import reverse from segment import analytics from ansible_ai_connect.ai.api.tests.test_views import ( MockedPipelineCompletions, WisdomAppsBackendMocking, ) +from ansible_ai_connect.ai.api.utils.version import api_version_reverse from ansible_ai_connect.test_utils import WisdomServiceAPITestCaseBaseOIDC @@ -82,7 +82,7 @@ def test_full_payload(self): Mock(return_value=MockedPipelineCompletions(self, expected, response_data)), ): with self.assertLogs(logger="root", level="DEBUG") as log: - r = self.client.post(reverse("completions"), payload, format="json") + r = self.client.post(api_version_reverse("completions"), payload, format="json") self.assertEqual(r.status_code, HTTPStatus.OK) self.assertIsNotNone(r.data["predictions"]) self.assertInLog("DEBUG:segment:queueing:", log) @@ -121,7 +121,7 @@ def test_full_payload(self): with self.assertLogs(logger="root", level="DEBUG") as log: r = self.client.post( - reverse("completions"), + api_version_reverse("completions"), urlencode(payload), content_type="application/x-www-form-urlencoded", ) @@ -137,7 +137,9 @@ def test_full_payload(self): with self.assertLogs(logger="root", level="DEBUG") as log: r = self.client.post( - reverse("completions"), urlencode(payload), content_type="application/json" + api_version_reverse("completions"), + urlencode(payload), + content_type="application/json", ) self.assertEqual(r.status_code, HTTPStatus.BAD_REQUEST) self.assertInLog("DEBUG:segment:queueing:", log) @@ -161,7 +163,7 @@ def test_preprocess_error(self, preprocess): self.client.force_authenticate(user=self.user) with self.assertLogs(logger="root", level="DEBUG") as log: - self.client.post(reverse("completions"), payload, format="json") + self.client.post(api_version_reverse("completions"), payload, format="json") self.assertInLog( "ERROR:ansible_ai_connect.ai.api.pipelines.completion_stages.pre_process:failed" " to preprocess:", @@ -200,7 +202,7 @@ def test_segment_error(self): Mock(return_value=MockedPipelineCompletions(self, payload, response_data)), ): with self.assertLogs(logger="root", level="DEBUG") as log: - r = self.client.post(reverse("completions"), payload, format="json") + r = self.client.post(api_version_reverse("completions"), payload, format="json") analytics.flush() self.assertEqual(r.status_code, HTTPStatus.OK) self.assertIsNotNone(r.data["predictions"]) @@ -246,7 +248,7 @@ def test_204_empty_response(self): Mock(return_value=MockedPipelineCompletions(self, payload, response_data)), ): with self.assertLogs(logger="root", level="DEBUG") as log: - r = self.client.post(reverse("completions"), payload, format="json") + r = self.client.post(api_version_reverse("completions"), payload, format="json") analytics.flush() self.assertEqual(r.status_code, HTTPStatus.NO_CONTENT) self.assertIsNone(r.data) @@ -315,7 +317,7 @@ def test_segment_error_with_data_exceeding_limit(self): Mock(return_value=MockedPipelineCompletions(self, payload, response_data)), ): with self.assertLogs(logger="root", level="DEBUG") as log: - self.client.post(reverse("completions"), payload, format="json") + self.client.post(api_version_reverse("completions"), payload, format="json") analytics.flush() self.assertInLog("Message exceeds 32kb limit. msg_len=", log) self.assertInLog("sent segment event: segmentError", log) diff --git a/ansible_ai_connect/main/tests/test_permissions.py b/ansible_ai_connect/main/tests/test_permissions.py index 84306fb7a..72711c260 100644 --- a/ansible_ai_connect/main/tests/test_permissions.py +++ b/ansible_ai_connect/main/tests/test_permissions.py @@ -15,8 +15,9 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser, Group from django.test import RequestFactory, TestCase -from django.urls import resolve, reverse +from django.urls import resolve +from ansible_ai_connect.ai.api.utils.version import api_version_reverse from ansible_ai_connect.main.permissions import IsRHInternalUser, IsTestUser @@ -29,7 +30,7 @@ def setUp(self): payload = { "query": "Hello", } - self.request = RequestFactory().post(reverse("chat"), payload, format="json") + self.request = RequestFactory().post(api_version_reverse("chat"), payload, format="json") self.non_rh_user = get_user_model().objects.create_user( username="non-rh-user", @@ -50,7 +51,7 @@ def tearDown(self): def get_permission(self, user): self.request.user = user - return self.permission.has_permission(self.request, resolve(reverse("chat"))) + return self.permission.has_permission(self.request, resolve(api_version_reverse("chat"))) def test_permission_with_rh_user(self): self.client.force_login(user=self.rh_user) @@ -76,7 +77,7 @@ def setUp(self): payload = { "query": "Hello", } - self.request = RequestFactory().post(reverse("chat"), payload, format="json") + self.request = RequestFactory().post(api_version_reverse("chat"), payload, format="json") self.non_rh_user = get_user_model().objects.create_user( username="non-rh-user", @@ -101,7 +102,7 @@ def tearDown(self): def get_permission(self, user): self.request.user = user - return self.permission.has_permission(self.request, resolve(reverse("chat"))) + return self.permission.has_permission(self.request, resolve(api_version_reverse("chat"))) def test_permission_with_non_rh_user(self): self.client.force_login(user=self.non_rh_user) diff --git a/ansible_ai_connect/main/tests/test_views.py b/ansible_ai_connect/main/tests/test_views.py index 53158cce4..f34a8a250 100644 --- a/ansible_ai_connect/main/tests/test_views.py +++ b/ansible_ai_connect/main/tests/test_views.py @@ -25,6 +25,7 @@ from django.urls import reverse from rest_framework.test import APITransactionTestCase +from ansible_ai_connect.ai.api.utils.version import api_version_reverse from ansible_ai_connect.main.settings.base import SOCIAL_AUTH_OIDC_KEY from ansible_ai_connect.main.views import LoginView from ansible_ai_connect.test_utils import create_user_with_provider @@ -176,7 +177,7 @@ def test_get_view(self): user = create_user_with_provider(provider=USER_SOCIAL_AUTH_PROVIDER_OIDC) self.client.force_login(user=user) - r = self.client.get(reverse("me_summary")) + r = self.client.get(api_version_reverse("me_summary")) self.assertEqual(r.status_code, 200) self.assertContains(r, "Logged in as: test_user_name") @@ -193,7 +194,7 @@ def test_get_view_trial(self): up.save() user.userplan_set.first().expired_at = "" - r = self.client.get(reverse("me_summary")) + r = self.client.get(api_version_reverse("me_summary")) self.assertEqual(r.status_code, 200) content = r.json()["content"] expired_at = up.expired_at.strftime("%Y-%m-%d") @@ -226,7 +227,7 @@ def test_get_view_expired_trial(self): up.save() user.userplan_set.first().expired_at = "" - r = self.client.get(reverse("me_summary")) + r = self.client.get(api_version_reverse("me_summary")) self.assertEqual(r.status_code, 200) content = r.json()["content"] self.assertTrue( diff --git a/ansible_ai_connect/main/urls.py b/ansible_ai_connect/main/urls.py index 56a19e460..0d94e39a4 100644 --- a/ansible_ai_connect/main/urls.py +++ b/ansible_ai_connect/main/urls.py @@ -38,9 +38,7 @@ ) from oauth2_provider.urls import app_name, base_urlpatterns -from ansible_ai_connect.ai.api.telemetry.api_telemetry_settings_views import ( - TelemetrySettingsView, -) +from ansible_ai_connect.ai.api import urls as api_urls from ansible_ai_connect.healthcheck.views import ( WisdomServiceHealthView, WisdomServiceLivenessProbeView, @@ -52,15 +50,7 @@ LogoutView, MetricsView, ) -from ansible_ai_connect.users.views import ( - CurrentUserView, - HomeView, - MarkdownCurrentUserView, - TrialView, - UnauthorizedView, -) - -WISDOM_API_VERSION = "v0" +from ansible_ai_connect.users.views import HomeView, TrialView, UnauthorizedView urlpatterns = [ path("", HomeView.as_view(), name="home"), @@ -70,13 +60,7 @@ # Adding a trailing slash breaks our metric collection in all sorts of ways. path("metrics", MetricsView.as_view(), name="prometheus-metrics"), path("admin/", admin.site.urls), - path(f"api/{WISDOM_API_VERSION}/ai/", include("ansible_ai_connect.ai.api.urls")), - path(f"api/{WISDOM_API_VERSION}/me/", CurrentUserView.as_view(), name="me"), - path( - f"api/{WISDOM_API_VERSION}/me/summary/", - MarkdownCurrentUserView.as_view(), - name="me_summary", - ), + path("api/", include(api_urls)), path("unauthorized/", UnauthorizedView.as_view(), name="unauthorized"), path("check/status/", WisdomServiceHealthView.as_view(), name="health_check"), path("check/", WisdomServiceLivenessProbeView.as_view(), name="liveness_probe"), @@ -92,15 +76,9 @@ if settings.DEBUG or settings.DEPLOYMENT_MODE == "saas": urlpatterns += [ - path(f"api/{WISDOM_API_VERSION}/wca/", include("ansible_ai_connect.ai.api.wca.urls")), path("console/", ConsoleView.as_view(), name="console"), path("console//", ConsoleView.as_view(), name="console"), path("console///", ConsoleView.as_view(), name="console"), - path( - f"api/{WISDOM_API_VERSION}/telemetry/", - TelemetrySettingsView.as_view(), - name="telemetry_settings", - ), path("chatbot/", ChatbotView.as_view(), name="chatbot"), ] diff --git a/ansible_ai_connect/users/tests/test_users.py b/ansible_ai_connect/users/tests/test_users.py index 9907250f6..a2ac39e36 100644 --- a/ansible_ai_connect/users/tests/test_users.py +++ b/ansible_ai_connect/users/tests/test_users.py @@ -30,6 +30,7 @@ IsOrganisationAdministrator, IsOrganisationLightspeedSubscriber, ) +from ansible_ai_connect.ai.api.utils.version import api_version_reverse from ansible_ai_connect.organizations.models import Organization from ansible_ai_connect.test_utils import ( WisdomAppsBackendMocking, @@ -57,7 +58,7 @@ def setUp(self) -> None: def test_users(self): self.client.force_authenticate(user=self.user) - r = self.client.get(reverse("me")) + r = self.client.get(api_version_reverse("me")) self.assertEqual(r.status_code, HTTPStatus.OK) self.assertEqual(self.user.username, r.data.get("username")) @@ -243,7 +244,7 @@ def test_github_user_login(self): external_username=external_username, ) self.client.force_authenticate(user=user) - r = self.client.get(reverse("me")) + r = self.client.get(api_version_reverse("me")) self.assertEqual(r.status_code, HTTPStatus.OK) self.assertEqual(external_username, r.data.get("external_username")) self.assertNotEqual(user.username, r.data.get("external_username")) @@ -255,7 +256,7 @@ def test_rhsso_user_login(self): external_username=external_username, ) self.client.force_authenticate(user=user) - r = self.client.get(reverse("me")) + r = self.client.get(api_version_reverse("me")) self.assertEqual(r.status_code, HTTPStatus.OK) self.assertEqual(external_username, r.data.get("external_username")) self.assertNotEqual(user.username, r.data.get("external_username")) @@ -272,11 +273,11 @@ def test_user_login_with_same_usernames(self): ) self.client.force_authenticate(user=oidc_user) - r = self.client.get(reverse("me")) + r = self.client.get(api_version_reverse("me")) self.assertEqual(external_username, r.data.get("external_username")) self.client.force_authenticate(user=github_user) - r = self.client.get(reverse("me")) + r = self.client.get(api_version_reverse("me")) self.assertEqual(external_username, r.data.get("external_username")) self.assertNotEqual(oidc_user.username, github_user.username) @@ -326,7 +327,7 @@ def test_github_user(self): external_username="github_username", ) self.client.force_authenticate(user=user) - r = self.client.get(reverse("me")) + r = self.client.get(api_version_reverse("me")) self.assertEqual(r.status_code, HTTPStatus.OK) self.assertTrue(r.data.get("org_telemetry_opt_out")) @@ -337,7 +338,7 @@ def test_aap_user(self): external_username="aap_username", ) self.client.force_authenticate(user=user) - r = self.client.get(reverse("me")) + r = self.client.get(api_version_reverse("me")) self.assertEqual(r.status_code, HTTPStatus.OK) self.assertTrue(r.data.get("org_telemetry_opt_out")) @@ -349,7 +350,7 @@ def test_rhsso_user_with_telemetry_opted_in(self): org_opt_out=False, ) self.client.force_authenticate(user=user) - r = self.client.get(reverse("me")) + r = self.client.get(api_version_reverse("me")) self.assertEqual(r.status_code, HTTPStatus.OK) self.assertFalse(r.data.get("org_telemetry_opt_out")) @@ -364,7 +365,7 @@ def test_rhsso_user_with_telemetry_opted_out(self, LDClient): org_opt_out=True, ) self.client.force_authenticate(user=user) - r = self.client.get(reverse("me")) + r = self.client.get(api_version_reverse("me")) self.assertEqual(r.status_code, HTTPStatus.OK) self.assertTrue(r.data.get("org_telemetry_opt_out")) @@ -382,20 +383,20 @@ def test_rhsso_user_caching(self, LDClient, *args): self.client.force_authenticate(user=user) # Default is False - r = self.client.get(reverse("me")) + r = self.client.get(api_version_reverse("me")) self.assertEqual(r.status_code, HTTPStatus.OK) self.assertFalse(r.data.get("org_telemetry_opt_out")) # Update to True r = self.client.post( - reverse("telemetry_settings"), + api_version_reverse("telemetry_settings"), data='{ "optOut": "True" }', content_type="application/json", ) self.assertEqual(r.status_code, HTTPStatus.NO_CONTENT) # Cached value should persist - r = self.client.get(reverse("me")) + r = self.client.get(api_version_reverse("me")) self.assertEqual(r.status_code, HTTPStatus.OK) self.assertFalse(r.data.get("org_telemetry_opt_out")) @@ -403,6 +404,6 @@ def test_rhsso_user_caching(self, LDClient, *args): cache.clear() # Cache should update - r = self.client.get(reverse("me")) + r = self.client.get(api_version_reverse("me")) self.assertEqual(r.status_code, HTTPStatus.OK) self.assertTrue(r.data.get("org_telemetry_opt_out")) diff --git a/tools/openapi-schema/ansible-ai-connect-service.yaml b/tools/openapi-schema/ansible-ai-connect-service.yaml index dd8fbf781..5008d44e9 100644 --- a/tools/openapi-schema/ansible-ai-connect-service.yaml +++ b/tools/openapi-schema/ansible-ai-connect-service.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Ansible AI Connect. - version: 0.0.9 + version: 0.0.9 (v0) description: Equip the automation developer at Lightspeed. paths: /api/v0/ai/chat/: