diff --git a/backend/openedx_ai_extensions/api/v1/urls.py b/backend/openedx_ai_extensions/api/v1/urls.py index 6b9e9d17..9ff111ed 100644 --- a/backend/openedx_ai_extensions/api/v1/urls.py +++ b/backend/openedx_ai_extensions/api/v1/urls.py @@ -4,10 +4,10 @@ from django.urls import path -from .pipelines.views import AIGenericWorkflowView +from .workflows.views import AIGenericWorkflowView app_name = "v1" urlpatterns = [ - path("workflows/", AIGenericWorkflowView.as_view(), name="ai_pipelines"), + path("workflows/", AIGenericWorkflowView.as_view(), name="ai_workflows"), ] diff --git a/backend/openedx_ai_extensions/api/v1/pipelines/__init__.py b/backend/openedx_ai_extensions/api/v1/workflows/__init__.py similarity index 100% rename from backend/openedx_ai_extensions/api/v1/pipelines/__init__.py rename to backend/openedx_ai_extensions/api/v1/workflows/__init__.py diff --git a/backend/openedx_ai_extensions/api/v1/pipelines/views.py b/backend/openedx_ai_extensions/api/v1/workflows/views.py similarity index 100% rename from backend/openedx_ai_extensions/api/v1/pipelines/views.py rename to backend/openedx_ai_extensions/api/v1/workflows/views.py diff --git a/backend/openedx_ai_extensions/settings/__init__.py b/backend/openedx_ai_extensions/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 00000000..e5d59484 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python +""" +Tests for the `openedx-ai-extensions` API endpoints. +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from django.contrib.auth import get_user_model +from django.test import Client +from django.urls import reverse +from opaque_keys.edx.keys import CourseKey + +User = get_user_model() + + +@pytest.fixture +def user(): + """ + Create and return a test user. + """ + return User.objects.create_user( + username="testuser", email="testuser@example.com", password="password123" + ) + + +@pytest.fixture +def staff_user(): + """ + Create and return a test staff user. + """ + return User.objects.create_user( + username="staffuser", + email="staffuser@example.com", + password="password123", + is_staff=True, + ) + + +@pytest.fixture +def course_key(): + """ + Create and return a test course key. + """ + return CourseKey.from_string("course-v1:edX+DemoX+Demo_Course") + + +@pytest.fixture +def client(): + """ + Create and return a Django test client. + """ + return Client() + + +@pytest.mark.django_db +def test_api_urls_are_registered(): + """ + Test that the API URLs are properly registered and accessible. + """ + # Test that the v1 workflows URL can be reversed + url = reverse("openedx_ai_extensions:api:v1:ai_workflows") + assert url == "/openedx-ai-extensions/v1/workflows/" + + +# @pytest.mark.django_db +# def test_api_namespace_exists(): +# """ +# Test that the API namespace is properly configured. +# """ +# # This will raise NoReverseMatch if the namespace doesn't exist +# url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") +# assert url is not None + + +# @pytest.mark.django_db +# def test_unauthenticated_request_redirects(client): +# """ +# Test that unauthenticated requests are redirected to login. +# """ +# url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") +# response = client.get(url) + +# # Django login_required decorator redirects to login page +# assert response.status_code == 302 +# assert "/accounts/login/" in response.url + + +# @pytest.mark.django_db +# def test_authenticated_get_request(client, user): +# """ +# Test GET request with authenticated user. +# """ +# client.force_login(user) +# url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + +# with patch("openedx_ai_extensions.workflows.models.AIWorkflow.find_workflow_for_context") as mock_find: +# # Mock the workflow creation and execution +# mock_workflow = MagicMock() +# mock_workflow.execute.return_value = { +# "status": "success", +# "result": "Test result", +# } +# mock_find.return_value = (mock_workflow, True) + +# response = client.get(url) + +# assert response.status_code == 200 +# data = json.loads(response.content) +# assert data["status"] == "success" +# assert "timestamp" in data +# assert "workflow_created" in data + + +# @pytest.mark.django_db +# def test_authenticated_post_request_with_valid_data(client, user, course_key): +# """ +# Test POST request with valid workflow data. +# """ +# client.force_login(user) +# url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + +# payload = { +# "action": "summarize", +# "courseId": str(course_key), +# "context": { +# "unit_id": "unit-123", +# }, +# "user_input": { +# "query": "Summarize this content", +# }, +# "requestId": "test-request-123", +# } + +# with patch("openedx_ai_extensions.workflows.models.AIWorkflow.find_workflow_for_context") as mock_find: +# mock_workflow = MagicMock() +# mock_workflow.execute.return_value = { +# "status": "success", +# "result": "Summary of the content", +# } +# mock_find.return_value = (mock_workflow, False) + +# response = client.post( +# url, +# data=json.dumps(payload), +# content_type="application/json", +# ) + +# assert response.status_code == 200 +# data = json.loads(response.content) +# assert data["status"] == "success" +# assert data["requestId"] == "test-request-123" +# assert data["workflow_created"] is False +# assert "timestamp" in data + +# # Verify the workflow was called correctly +# mock_find.assert_called_once() +# call_kwargs = mock_find.call_args[1] +# assert call_kwargs["action"] == "summarize" +# assert call_kwargs["course_id"] == str(course_key) +# assert call_kwargs["user"] == user + + +# @pytest.mark.django_db +# def test_post_request_with_invalid_json(client, user): +# """ +# Test POST request with invalid JSON body. +# """ +# client.force_login(user) +# url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + +# response = client.post( +# url, +# data="invalid json{", +# content_type="application/json", +# ) + +# assert response.status_code == 400 +# data = json.loads(response.content) +# assert data["status"] == "error" +# assert "Invalid JSON" in data["error"] + + +# @pytest.mark.django_db +# def test_post_request_with_validation_error(client, user): +# """ +# Test POST request that triggers a ValidationError. +# """ +# client.force_login(user) +# url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + +# payload = { +# "action": "invalid_action", +# "courseId": "invalid-course-id", +# } + +# with patch("openedx_ai_extensions.workflows.models.AIWorkflow.find_workflow_for_context") as mock_find: +# from django.core.exceptions import ValidationError +# mock_find.side_effect = ValidationError("Invalid workflow configuration") + +# response = client.post( +# url, +# data=json.dumps(payload), +# content_type="application/json", +# ) + +# assert response.status_code == 400 +# data = json.loads(response.content) +# assert data["status"] == "validation_error" +# assert "Invalid workflow configuration" in data["error"] + + +# @pytest.mark.django_db +# def test_post_request_with_workflow_error(client, user): +# """ +# Test POST request where workflow execution fails. +# """ +# client.force_login(user) +# url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + +# payload = { +# "action": "summarize", +# "courseId": "course-v1:edX+Test+2024", +# } + +# with patch("openedx_ai_extensions.workflows.models.AIWorkflow.find_workflow_for_context") as mock_find: +# mock_workflow = MagicMock() +# mock_workflow.execute.return_value = { +# "status": "error", +# "error": "LLM processing failed", +# } +# mock_find.return_value = (mock_workflow, True) + +# response = client.post( +# url, +# data=json.dumps(payload), +# content_type="application/json", +# ) + +# assert response.status_code == 500 +# data = json.loads(response.content) +# assert data["status"] == "error" + + +# @pytest.mark.django_db +# def test_post_request_with_bad_request_status(client, user): +# """ +# Test POST request where workflow returns a bad_request status. +# """ +# client.force_login(user) +# url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + +# payload = { +# "action": "summarize", +# } + +# with patch("openedx_ai_extensions.workflows.models.AIWorkflow.find_workflow_for_context") as mock_find: +# mock_workflow = MagicMock() +# mock_workflow.execute.return_value = { +# "status": "bad_request", +# "error": "Missing required context", +# } +# mock_find.return_value = (mock_workflow, True) + +# response = client.post( +# url, +# data=json.dumps(payload), +# content_type="application/json", +# ) + +# assert response.status_code == 400 +# data = json.loads(response.content) +# assert data["status"] == "bad_request" + + +# @pytest.mark.django_db +# def test_post_request_with_exception(client, user): +# """ +# Test POST request where an unexpected exception occurs. +# """ +# client.force_login(user) +# url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + +# payload = { +# "action": "summarize", +# } + +# with patch("openedx_ai_extensions.workflows.models.AIWorkflow.find_workflow_for_context") as mock_find: +# mock_find.side_effect = Exception("Unexpected error occurred") + +# response = client.post( +# url, +# data=json.dumps(payload), +# content_type="application/json", +# ) + +# assert response.status_code == 500 +# data = json.loads(response.content) +# assert data["status"] == "error" +# assert "Unexpected error occurred" in data["error"] + + +# @pytest.mark.django_db +# def test_post_request_without_request_id(client, user): +# """ +# Test POST request without requestId in payload. +# """ +# client.force_login(user) +# url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + +# payload = { +# "action": "quiz_generate", +# "courseId": "course-v1:edX+Test+2024", +# } + +# with patch("openedx_ai_extensions.workflows.models.AIWorkflow.find_workflow_for_context") as mock_find: +# mock_workflow = MagicMock() +# mock_workflow.execute.return_value = { +# "status": "success", +# } +# mock_find.return_value = (mock_workflow, True) + +# response = client.post( +# url, +# data=json.dumps(payload), +# content_type="application/json", +# ) + +# assert response.status_code == 200 +# data = json.loads(response.content) +# # Should default to "no-request-id" +# assert data["requestId"] == "no-request-id" + + +# @pytest.mark.django_db +# def test_empty_post_request(client, user): +# """ +# Test POST request with empty body. +# """ +# client.force_login(user) +# url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + +# with patch("openedx_ai_extensions.workflows.models.AIWorkflow.find_workflow_for_context") as mock_find: +# mock_workflow = MagicMock() +# mock_workflow.execute.return_value = { +# "status": "success", +# } +# mock_find.return_value = (mock_workflow, True) + +# response = client.post(url) + +# # Should handle empty body gracefully +# assert response.status_code == 200 +# mock_find.assert_called_once() +# call_kwargs = mock_find.call_args[1] +# assert call_kwargs["action"] is None +# assert call_kwargs["course_id"] is None +# assert call_kwargs["context"] == {} + + +# @pytest.mark.django_db +# def test_staff_user_can_access_workflow(client, staff_user): +# """ +# Test that staff users can access the workflow endpoint. +# """ +# client.force_login(staff_user) +# url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + +# with patch("openedx_ai_extensions.workflows.models.AIWorkflow.find_workflow_for_context") as mock_find: +# mock_workflow = MagicMock() +# mock_workflow.execute.return_value = { +# "status": "success", +# } +# mock_find.return_value = (mock_workflow, True) + +# response = client.get(url) + +# assert response.status_code == 200 +# data = json.loads(response.content) +# assert data["status"] == "success" diff --git a/frontend/Makefile b/frontend/Makefile index b5dc61bd..fb6a9f4e 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -11,5 +11,5 @@ build: @# --copy-files will bring in everything else that wasn't processed by babel. Remove what we don't want. @find dist -name '*.test.js*' -delete cp ./package.json ./dist/package.json - cp ./LICENSE ./dist/LICENSE 2>/dev/null || cp ../LICENSE ./dist/LICENSE + cp ./LICENSE ./dist/LICENSE 2>/dev/null || cp ../LICENSE ./dist/LICENSE 2>/dev/null || true cp ./README.md ./dist/README.md diff --git a/frontend/package.json b/frontend/package.json index df71c08e..c27a39a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "@openedx/openedx-aiext-ui", + "name": "@openedx/openedx-ai-extensions-ui", "version": "0.0.1", "description": "AI Extensions UI component for Open edX utilizing Frontend Plugin Framework", "main": "dist/index.js", diff --git a/tutor/openedx_ai_extensions/__about__.py b/tutor/openedx_ai_extensions/__about__.py new file mode 100644 index 00000000..9c9eb915 --- /dev/null +++ b/tutor/openedx_ai_extensions/__about__.py @@ -0,0 +1 @@ +__version__ = "20.0.0" diff --git a/tutor/openedx_ai_extensions/__init__.py b/tutor/openedx_ai_extensions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tutor/openedx_ai_extensions/patches/mfe-dockerfile-post-npm-install-learning b/tutor/openedx_ai_extensions/patches/mfe-dockerfile-post-npm-install-learning new file mode 100644 index 00000000..8207bf10 --- /dev/null +++ b/tutor/openedx_ai_extensions/patches/mfe-dockerfile-post-npm-install-learning @@ -0,0 +1,3 @@ +### POST NPM PATCH LEARNING +RUN npm install --legacy-peer-deps --install-links /openedx/ai-extensions-frontend +## \ No newline at end of file diff --git a/tutor/openedx_ai_extensions/patches/mfe-dockerfile-pre-npm-install-learning b/tutor/openedx_ai_extensions/patches/mfe-dockerfile-pre-npm-install-learning new file mode 100644 index 00000000..69cd1093 --- /dev/null +++ b/tutor/openedx_ai_extensions/patches/mfe-dockerfile-pre-npm-install-learning @@ -0,0 +1,7 @@ +### PRE NPM PATCH LEARNING +COPY --from=ai-extensions-frontend / /openedx/ai-extensions-frontend + +WORKDIR /openedx/ai-extensions-frontend +RUN npm install && npm run build +WORKDIR /openedx/app +## diff --git a/tutor/openedx_ai_extensions/patches/mfe-env-config-runtime-definitions-learning b/tutor/openedx_ai_extensions/patches/mfe-env-config-runtime-definitions-learning new file mode 100644 index 00000000..f176ed46 --- /dev/null +++ b/tutor/openedx_ai_extensions/patches/mfe-env-config-runtime-definitions-learning @@ -0,0 +1 @@ +const { GetAIAssistanceButton } = await import("@openedx/openedx-ai-extensions-ui"); diff --git a/tutor/openedx_ai_extensions/patches/openedx-dev-dockerfile-post-python-requirements b/tutor/openedx_ai_extensions/patches/openedx-dev-dockerfile-post-python-requirements new file mode 100644 index 00000000..4b5038ed --- /dev/null +++ b/tutor/openedx_ai_extensions/patches/openedx-dev-dockerfile-post-python-requirements @@ -0,0 +1,6 @@ +# Patch openedx-dev-dockerfile-post-python-requirements used for openedx-ai-extensions + +COPY --from=ai-extensions-backend / /openedx/ai-extensions-backend +USER root +RUN pip install -e /openedx/ai-extensions-backend +USER app diff --git a/tutor/openedx_ai_extensions/patches/openedx-dockerfile-post-python-requirements b/tutor/openedx_ai_extensions/patches/openedx-dockerfile-post-python-requirements new file mode 100644 index 00000000..ef3d1af6 --- /dev/null +++ b/tutor/openedx_ai_extensions/patches/openedx-dockerfile-post-python-requirements @@ -0,0 +1,4 @@ +# Patch openedx-dockerfile-post-python-requirements used for openedx-ai-extensions + +COPY --from=ai-extensions-backend / /openedx/ai-extensions-backend +RUN pip install /openedx/ai-extensions-backend diff --git a/tutor/openedx_ai_extensions/plugin.py b/tutor/openedx_ai_extensions/plugin.py new file mode 100644 index 00000000..733acee9 --- /dev/null +++ b/tutor/openedx_ai_extensions/plugin.py @@ -0,0 +1,60 @@ +import os +from glob import glob +from pathlib import Path + +import importlib_resources +from tutor import hooks +from tutormfe.hooks import MFE_APPS, PLUGIN_SLOTS + +from .__about__ import __version__ + + +######################## +# Plugin path management +######################## + +PLUGIN_DIR = Path(__file__).parent +REPO_ROOT = PLUGIN_DIR.parent.parent +FRONTEND_PATH = REPO_ROOT / "frontend" +BACKEND_PATH = REPO_ROOT / "backend" + +# Makes the UI Slots code available for local install during the build process +hooks.Filters.DOCKER_BUILD_COMMAND.add_items([ + "--build-context", f"ai-extensions-frontend={str(FRONTEND_PATH)}", + "--build-context", f"ai-extensions-backend={str(BACKEND_PATH)}", +]) + +@hooks.Filters.IMAGES_BUILD_MOUNTS.add() +def _mount_sample_plugin(mounts, path): + """Mount the sample plugin source code for development.""" + mounts += [("openedx-ai-extensions/backend", "/openedx/openedx-ai-extensions/backend")] + return mounts + +# Actually connects the patch files as tutor env patches +for path in glob(str(importlib_resources.files("openedx_ai_extensions") / "patches" / "*")): + with open(path, encoding="utf-8") as patch_file: + hooks.Filters.ENV_PATCHES.add_item((os.path.basename(path), patch_file.read())) + + +######################## +# UI Slot configurations +######################## + +PLUGIN_SLOTS.add_items( + [ + ( + "learning", + "org.openedx.frontend.learning.unit_title.v1", + """ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: 'ai-assist-button', + priority: 10, + type: DIRECT_PLUGIN, + RenderWidget: GetAIAssistanceButton, + }, + }""", + ), + ] +) diff --git a/tutor/setup.py b/tutor/setup.py new file mode 100644 index 00000000..b6cc39c2 --- /dev/null +++ b/tutor/setup.py @@ -0,0 +1,12 @@ +from setuptools import setup + +setup( + name="openedx-ai-extensions", + version="0.1.0", + packages=["openedx_ai_extensions"], + entry_points={ + "tutor.plugin.v1": [ + "openedx-ai-extensions = openedx_ai_extensions.plugin" + ] + }, +)