From 4cce2c5af812d4e3f8421a4829a5cc98d8b57d48 Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Tue, 24 Mar 2026 15:36:57 -0500 Subject: [PATCH 01/13] feat: adding backend API to list all profiles within the current scope Co-Authored-By: Claude Sonnet 4.6 --- backend/openedx_ai_extensions/api/v1/urls.py | 3 +- .../api/v1/workflows/serializers.py | 80 ++++ .../api/v1/workflows/views.py | 80 +++- .../openedx_ai_extensions/workflows/models.py | 71 ++++ backend/tests/test_api.py | 390 ++++++++++++++++++ 5 files changed, 622 insertions(+), 2 deletions(-) diff --git a/backend/openedx_ai_extensions/api/v1/urls.py b/backend/openedx_ai_extensions/api/v1/urls.py index bdca806f..bffbef00 100644 --- a/backend/openedx_ai_extensions/api/v1/urls.py +++ b/backend/openedx_ai_extensions/api/v1/urls.py @@ -4,11 +4,12 @@ from django.urls import path -from .workflows.views import AIGenericWorkflowView, AIWorkflowProfileView +from .workflows.views import AIGenericWorkflowView, AIWorkflowProfilesListView, AIWorkflowProfileView app_name = "v1" urlpatterns = [ path("workflows/", AIGenericWorkflowView.as_view(), name="aiext_workflows"), path("profile/", AIWorkflowProfileView.as_view(), name="aiext_ui_config"), + path("profiles/", AIWorkflowProfilesListView.as_view(), name="aiext_profiles_list"), ] diff --git a/backend/openedx_ai_extensions/api/v1/workflows/serializers.py b/backend/openedx_ai_extensions/api/v1/workflows/serializers.py index 21bd6708..db55340f 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/serializers.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/serializers.py @@ -2,8 +2,59 @@ Serializers for AI Workflows API """ +import copy + from rest_framework import serializers +# Keys whose values must never be exposed to the frontend. +_SENSITIVE_KEYS = frozenset({ + "api_key", + "apikey", + "secret", + "password", + "token", +}) + + +def redact_sensitive_config(config): + """ + Return a deep copy of config with sensitive leaf values redacted. + + Recursively walks nested dicts and lists. Any dict key that matches + a name in ``_SENSITIVE_KEYS`` (case-insensitive) has its value replaced + with the string ``"[REDACTED]"``. + + Args: + config (dict): Workflow effective configuration dict. + + Returns: + dict: New dict with sensitive values replaced. + """ + config_copy = copy.deepcopy(config) + return _redact_node(config_copy) + + +def _redact_node(node): + """ + Recursively redact sensitive keys from a dict or list node. + + Args: + node: A dict, list, or scalar value. + + Returns: + The node with sensitive values replaced. + """ + if isinstance(node, dict): + for key in node: + if key.lower() in _SENSITIVE_KEYS: + node[key] = "[REDACTED]" + else: + node[key] = _redact_node(node[key]) + elif isinstance(node, list): + for i, item in enumerate(node): + node[i] = _redact_node(item) + return node + class AIWorkflowProfileSerializer(serializers.Serializer): """ @@ -26,3 +77,32 @@ def create(self, validated_data): def update(self, instance, validated_data): """Read-only serializer β€” update not supported.""" raise NotImplementedError("AIWorkflowProfileSerializer is read-only") + + +class AIWorkflowProfileListSerializer(serializers.Serializer): + """ + Serializer for a single AIWorkflowProfile in the profiles list endpoint. + + Exposes the profile's identity fields and its complete effective + configuration with sensitive values redacted. Designed to be extended + in future iterations to expose globally-configured provider information + alongside profile-level settings. + """ + + id = serializers.UUIDField(read_only=True) + slug = serializers.SlugField(read_only=True) + description = serializers.CharField(allow_null=True, read_only=True) + effective_config = serializers.SerializerMethodField() + + def get_effective_config(self, obj): + """Return effective config with sensitive values redacted.""" + config = obj.config or {} + return redact_sensitive_config(config) + + def create(self, validated_data): + """Read-only serializer β€” creation not supported.""" + raise NotImplementedError("AIWorkflowProfileListSerializer is read-only") + + def update(self, instance, validated_data): + """Read-only serializer β€” update not supported.""" + raise NotImplementedError("AIWorkflowProfileListSerializer is read-only") diff --git a/backend/openedx_ai_extensions/api/v1/workflows/views.py b/backend/openedx_ai_extensions/api/v1/workflows/views.py index a0335d7a..b9dc66c1 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/views.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/views.py @@ -23,7 +23,7 @@ from openedx_ai_extensions.utils import is_generator from openedx_ai_extensions.workflows.models import AIWorkflowScope -from .serializers import AIWorkflowProfileSerializer +from .serializers import AIWorkflowProfileListSerializer, AIWorkflowProfileSerializer logger = logging.getLogger(__name__) @@ -166,3 +166,81 @@ def get(self, request): response_data["timestamp"] = datetime.now().isoformat() return Response(response_data, status=status.HTTP_200_OK) + + +class AIWorkflowProfilesListView(APIView): + """ + API endpoint to list all AI Workflow Profiles matching a given context. + + Returns every distinct AIWorkflowProfile reachable for the requested + course_id / location_id / ui_slot_selector_id / service_variant combination. + Effective configurations are included with all sensitive values redacted. + + When no ``uiSlotSelectorId`` is provided, profiles for all slots are returned + β€” the intended call pattern for the Studio settings panel. + """ + + # TODO: elevate to course-staff permission once the edxapp_wrapper provides a + # has_course_author_access integration. Requires common.djangoapps.student.roles + # (edx-platform) which is not a standalone dependency of this plugin. + permission_classes = [IsAuthenticated] + + def get(self, request): + """ + List workflow profiles for the given context. + + Accepts the same ``context`` JSON query param as ``profile/``, with optional + ``courseId``, ``locationId``, ``uiSlotSelectorId``, and ``serviceVariant`` + keys. When ``serviceVariant`` is omitted, profiles for all service variants + are returned. + + Returns: + 200: {"profiles": [...], "count": N, "timestamp": "..."} + 400: Validation error (malformed course or location key) + 500: Unexpected server error + """ + try: + context = get_context_from_request(request) + + # service_variant is specific to this endpoint and is read directly + # from the raw context rather than through get_context_from_request. + raw_context = json.loads(request.query_params.get("context", "{}")) + service_variant = ( + raw_context.get("serviceVariant") or raw_context.get("service_variant") or None + ) + + profiles = AIWorkflowScope.list_profiles_for_context( + **context, service_variant=service_variant + ) + serializer = AIWorkflowProfileListSerializer(profiles, many=True) + + return Response( + { + "profiles": serializer.data, + "count": len(profiles), + "timestamp": datetime.now().isoformat(), + }, + status=status.HTTP_200_OK, + ) + + except ValidationError as e: + logger.warning("πŸ€– PROFILES LIST VALIDATION ERROR: %s", str(e)) + return Response( + { + "error": str(e), + "status": "validation_error", + "timestamp": datetime.now().isoformat(), + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + except Exception as e: # pylint: disable=broad-exception-caught + logger.exception("πŸ€– PROFILES LIST ERROR") + return Response( + { + "error": str(e), + "status": "error", + "timestamp": datetime.now().isoformat(), + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/backend/openedx_ai_extensions/workflows/models.py b/backend/openedx_ai_extensions/workflows/models.py index 06a867ed..de21aaba 100644 --- a/backend/openedx_ai_extensions/workflows/models.py +++ b/backend/openedx_ai_extensions/workflows/models.py @@ -301,6 +301,77 @@ def get_profile(cls, course_id=None, location_id=None, ui_slot_selector_id=None) return None + @classmethod + def list_profiles_for_context( + cls, course_id=None, location_id=None, ui_slot_selector_id=None, service_variant=None + ): + """ + Return all distinct AIWorkflowProfile objects reachable for the given context. + + Unlike ``get_profile``, which returns the single best-matching scope, this + method collects every enabled scope whose course_id and location_regex match + the context and returns the unique set of associated AIWorkflowProfile objects + (deduplicated by profile pk). + + When ``ui_slot_selector_id`` is provided, only scopes matching that exact + value or the empty-string wildcard are included. When it is omitted, all + profiles for the course are returned regardless of slot assignment β€” useful + for the Studio settings panel where all available workflows should be visible. + + When ``service_variant`` is provided, results are filtered to that variant + only. When omitted, scopes from all service variants are returned. + + Args: + course_id (str | None): Opaque course key string. + location_id (str | None): Opaque usage key string for location filtering. + ui_slot_selector_id (str | None): Optional UI slot filter. + service_variant (str | None): Optional service variant filter (``"lms"`` + or ``"cms"``). When ``None``, all variants are included. + + Returns: + list[AIWorkflowProfile]: Deduplicated list of matching profiles, ordered + by descending specificity_index of the first matching scope per profile. + """ + course_filter = Q(course_id=course_id) | Q(course_id=CourseKeyField.Empty) + base_filter = {"enabled": True} + if service_variant: + base_filter["service_variant"] = service_variant + + if ui_slot_selector_id: + candidates = cls.objects.filter( + course_filter, + Q(ui_slot_selector_id=ui_slot_selector_id) | Q(ui_slot_selector_id=""), + **base_filter, + ).select_related("profile").order_by("-specificity_index") + else: + candidates = cls.objects.filter( + course_filter, + **base_filter, + ).select_related("profile").order_by("-specificity_index") + + seen_profile_ids = set() + profiles = [] + + for scope in candidates: + if scope.location_regex is None: + # NULL location_regex is a wildcard β€” matches any location + pass + elif not location_id: + # Scope requires a specific location but none was provided β€” skip + continue + else: + try: + if not re.search(scope.location_regex, location_id): + continue + except re.error: + continue + + if scope.profile_id not in seen_profile_ids: + seen_profile_ids.add(scope.profile_id) + profiles.append(scope.profile) + + return profiles + def execute(self, user_input, action, user, running_context) -> dict[str, str | dict[str, str]] | Any: """ Execute this workflow using its configured orchestrator diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 6a0d8887..8beb1fb4 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -18,9 +18,12 @@ sys.modules["submissions.api"] = MagicMock() from openedx_ai_extensions.api.v1.workflows.serializers import ( # noqa: E402 pylint: disable=wrong-import-position + AIWorkflowProfileListSerializer, AIWorkflowProfileSerializer, + redact_sensitive_config, ) from openedx_ai_extensions.api.v1.workflows.views import ( # noqa: E402 pylint: disable=wrong-import-position + AIWorkflowProfilesListView, AIWorkflowProfileView, ) from openedx_ai_extensions.workflows.models import ( # noqa: E402 pylint: disable=wrong-import-position @@ -706,3 +709,390 @@ def test_workflow_config_view_invalid_context_json_unit( # Should return error status for invalid JSON assert response.status_code == 400 + + +# ============================================================================ +# Tests - Profiles List Endpoint (GET /v1/profiles/) +# ============================================================================ + + +@pytest.mark.django_db +def test_profiles_list_url_is_registered(): + """ + Test that the profiles list URL is properly registered and accessible. + """ + url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") + assert url == "/openedx-ai-extensions/v1/profiles/" + + +@pytest.mark.django_db +def test_profiles_list_requires_authentication(api_client): # pylint: disable=redefined-outer-name + """ + Test that the profiles list endpoint requires authentication. + """ + url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") + response = api_client.get(url) + assert response.status_code in [401, 403] + + +@pytest.mark.django_db +@pytest.mark.usefixtures("user") +def test_profiles_list_happy_path(api_client, course_key): # pylint: disable=redefined-outer-name + """ + Two scopes with different slots pointing to two distinct profiles are both returned. + """ + api_client.login(username="testuser", password="password123") + url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") + + profile_a = AIWorkflowProfile.objects.create( + slug="pl-happy-a", description="A", base_filepath="base/default.json", content_patch="{}" + ) + profile_b = AIWorkflowProfile.objects.create( + slug="pl-happy-b", description="B", base_filepath="base/default.json", content_patch="{}" + ) + AIWorkflowScope.objects.create( + course_id=course_key, service_variant="lms", profile=profile_a, + enabled=True, ui_slot_selector_id="slot-a", + ) + AIWorkflowScope.objects.create( + course_id=course_key, service_variant="lms", profile=profile_b, + enabled=True, ui_slot_selector_id="slot-b", + ) + + context = json.dumps({"courseId": str(course_key)}) + response = api_client.get(url, {"context": context}) + + assert response.status_code == 200 + data = response.json() + assert data["count"] == 2 + assert len(data["profiles"]) == 2 + slugs = {p["slug"] for p in data["profiles"]} + assert slugs == {"pl-happy-a", "pl-happy-b"} + for profile in data["profiles"]: + assert "id" in profile + assert "slug" in profile + assert "description" in profile + assert "effective_config" in profile + + +@pytest.mark.django_db +@pytest.mark.usefixtures("user") +def test_profiles_list_no_matches(api_client): # pylint: disable=redefined-outer-name + """ + Unknown course returns an empty list without errors. + """ + api_client.login(username="testuser", password="password123") + url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") + + context = json.dumps({"courseId": "course-v1:Unknown+X+NoSuchCourse"}) + response = api_client.get(url, {"context": context}) + + assert response.status_code == 200 + data = response.json() + assert data["count"] == 0 + assert data["profiles"] == [] + + +@pytest.mark.django_db +@pytest.mark.usefixtures("user") +@patch("openedx_ai_extensions.api.v1.workflows.views.AIWorkflowScope.list_profiles_for_context") +def test_profiles_list_api_keys_are_redacted( # pylint: disable=redefined-outer-name + mock_list, api_client, course_key +): + """ + API keys present in the effective config must not appear in the response. + + Uses a mock profile so the test is not affected by template-file availability. + The redact_sensitive_config unit tests verify the redaction logic independently. + """ + mock_profile = Mock() + mock_profile.id = "00000000-0000-0000-0000-000000000002" + mock_profile.slug = "pl-redact" + mock_profile.description = "Redact test" + mock_profile.config = { + "processor_config": {"LLMProcessor": {"options": {"api_key": "sk-secret-123"}}} + } + mock_list.return_value = [mock_profile] + + api_client.login(username="testuser", password="password123") + url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") + context = json.dumps({"courseId": str(course_key), "uiSlotSelectorId": "slot-redact"}) + response = api_client.get(url, {"context": context}) + + assert response.status_code == 200 + data = response.json() + assert data["count"] == 1 + effective_config = data["profiles"][0]["effective_config"] + processor_opts = effective_config.get("processor_config", {}).get("LLMProcessor", {}).get("options", {}) + assert processor_opts.get("api_key") == "[REDACTED]" + assert "sk-secret-123" not in json.dumps(data) + + +@pytest.mark.django_db +@pytest.mark.usefixtures("user") +def test_profiles_list_deduplication(api_client, course_key): # pylint: disable=redefined-outer-name + """ + Two scopes pointing to the same profile return only one profile entry. + """ + api_client.login(username="testuser", password="password123") + url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") + + profile = AIWorkflowProfile.objects.create( + slug="pl-dedup", description="Dedup test", base_filepath="base/default.json", content_patch="{}" + ) + AIWorkflowScope.objects.create( + course_id=course_key, service_variant="lms", profile=profile, + enabled=True, ui_slot_selector_id="slot-x", + ) + AIWorkflowScope.objects.create( + course_id=course_key, service_variant="lms", profile=profile, + enabled=True, ui_slot_selector_id="slot-y", + ) + + context = json.dumps({"courseId": str(course_key)}) + response = api_client.get(url, {"context": context}) + + assert response.status_code == 200 + data = response.json() + assert data["count"] == 1 + assert data["profiles"][0]["slug"] == "pl-dedup" + + +@pytest.mark.django_db +@pytest.mark.usefixtures("user") +def test_profiles_list_filtered_by_ui_slot(api_client, course_key): # pylint: disable=redefined-outer-name + """ + When uiSlotSelectorId is provided, only profiles for that slot are returned. + """ + api_client.login(username="testuser", password="password123") + url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") + + profile_a = AIWorkflowProfile.objects.create( + slug="pl-slot-a", description="A", base_filepath="base/default.json", content_patch="{}" + ) + profile_b = AIWorkflowProfile.objects.create( + slug="pl-slot-b", description="B", base_filepath="base/default.json", content_patch="{}" + ) + AIWorkflowScope.objects.create( + course_id=course_key, service_variant="lms", profile=profile_a, + enabled=True, ui_slot_selector_id="slot-wanted", + ) + AIWorkflowScope.objects.create( + course_id=course_key, service_variant="lms", profile=profile_b, + enabled=True, ui_slot_selector_id="slot-other", + ) + + context = json.dumps({"courseId": str(course_key), "uiSlotSelectorId": "slot-wanted"}) + response = api_client.get(url, {"context": context}) + + assert response.status_code == 200 + data = response.json() + assert data["count"] == 1 + assert data["profiles"][0]["slug"] == "pl-slot-a" + + +@pytest.mark.django_db +@pytest.mark.usefixtures("user") +def test_profiles_list_no_slot_returns_all(api_client, course_key): # pylint: disable=redefined-outer-name + """ + When no uiSlotSelectorId is provided, all profiles for the course are returned + regardless of their individual slot assignments. This is the intended call + pattern for the Studio settings panel. + """ + api_client.login(username="testuser", password="password123") + url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") + + profile_a = AIWorkflowProfile.objects.create( + slug="pl-noslot-a", description="A", base_filepath="base/default.json", content_patch="{}" + ) + profile_b = AIWorkflowProfile.objects.create( + slug="pl-noslot-b", description="B", base_filepath="base/default.json", content_patch="{}" + ) + AIWorkflowScope.objects.create( + course_id=course_key, service_variant="lms", profile=profile_a, + enabled=True, ui_slot_selector_id="slot-lms-widget", + ) + AIWorkflowScope.objects.create( + course_id=course_key, service_variant="lms", profile=profile_b, + enabled=True, ui_slot_selector_id="slot-another-widget", + ) + + context = json.dumps({"courseId": str(course_key)}) + response = api_client.get(url, {"context": context}) + + assert response.status_code == 200 + data = response.json() + assert data["count"] == 2 + slugs = {p["slug"] for p in data["profiles"]} + assert slugs == {"pl-noslot-a", "pl-noslot-b"} + + +@pytest.mark.django_db +@pytest.mark.usefixtures("user") +def test_profiles_list_invalid_course_id(api_client): # pylint: disable=redefined-outer-name + """ + Malformed courseId returns HTTP 400 with an error key. + """ + api_client.login(username="testuser", password="password123") + url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") + + context = json.dumps({"courseId": "not-a-valid-course-key"}) + response = api_client.get(url, {"context": context}) + + assert response.status_code == 400 + data = response.json() + assert "error" in data + + +@pytest.mark.django_db +@pytest.mark.usefixtures("user") +def test_profiles_list_no_context_param(api_client): # pylint: disable=redefined-outer-name + """ + Missing context param is treated as empty context β€” no crash, returns empty list. + """ + api_client.login(username="testuser", password="password123") + url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") + + response = api_client.get(url) + + assert response.status_code == 200 + data = response.json() + assert "count" in data + assert "profiles" in data + + +# ============================================================================ +# Unit Tests - redact_sensitive_config +# ============================================================================ + + +def test_redact_sensitive_config_top_level_key(): + """API_KEY at the top level is redacted.""" + config = {"api_key": "sk-secret", "model": "gpt-4"} + result = redact_sensitive_config(config) + assert result["api_key"] == "[REDACTED]" + assert result["model"] == "gpt-4" + + +def test_redact_sensitive_config_nested_key(): + """Sensitive key nested inside a sub-dict is redacted.""" + config = {"processor_config": {"LLMProcessor": {"options": {"API_KEY": "sk-nested"}}}} + result = redact_sensitive_config(config) + assert result["processor_config"]["LLMProcessor"]["options"]["API_KEY"] == "[REDACTED]" + + +def test_redact_sensitive_config_inside_list(): + """Sensitive key inside a list element is redacted.""" + config = {"tools": [{"name": "search", "token": "tok-abc"}]} + result = redact_sensitive_config(config) + assert result["tools"][0]["token"] == "[REDACTED]" + assert result["tools"][0]["name"] == "search" + + +def test_redact_sensitive_config_does_not_mutate_original(): + """Original config is not mutated.""" + config = {"api_key": "sk-original"} + redact_sensitive_config(config) + assert config["api_key"] == "sk-original" + + +def test_redact_sensitive_config_case_insensitive(): + """Redaction is case-insensitive on the key name.""" + config = {"API_KEY": "upper", "Api_Key": "mixed", "api_key": "lower"} + result = redact_sensitive_config(config) + for key in result: + assert result[key] == "[REDACTED]" + + +def test_redact_sensitive_config_non_sensitive_keys_preserved(): + """Non-sensitive keys like max_tokens are not redacted.""" + config = {"max_tokens": 4096, "temperature": 0.7} + result = redact_sensitive_config(config) + assert result["max_tokens"] == 4096 + assert result["temperature"] == 0.7 + + +# ============================================================================ +# Unit Tests - AIWorkflowProfileListSerializer +# ============================================================================ + + +def test_profile_list_serializer_create_not_implemented(): + """AIWorkflowProfileListSerializer.create raises NotImplementedError.""" + mock_profile = Mock() + mock_profile.config = {} + serializer = AIWorkflowProfileListSerializer(mock_profile) + with pytest.raises(NotImplementedError): + serializer.create({}) + + +def test_profile_list_serializer_update_not_implemented(): + """AIWorkflowProfileListSerializer.update raises NotImplementedError.""" + mock_profile = Mock() + mock_profile.config = {} + serializer = AIWorkflowProfileListSerializer(mock_profile) + with pytest.raises(NotImplementedError): + serializer.update(mock_profile, {}) + + +def test_profile_list_serializer_handles_none_config(): + """Serializer does not crash when profile.config is None.""" + mock_profile = Mock() + mock_profile.config = None + serializer = AIWorkflowProfileListSerializer(mock_profile) + effective = serializer.get_effective_config(mock_profile) + assert effective == {} + + +# ============================================================================ +# Unit Tests - AIWorkflowProfilesListView with mocks +# ============================================================================ + + +@pytest.mark.django_db +@patch("openedx_ai_extensions.api.v1.workflows.views.AIWorkflowScope.list_profiles_for_context") +def test_profiles_list_view_returns_200_unit(mock_list, user): # pylint: disable=redefined-outer-name + """ + AIWorkflowProfilesListView returns 200 with correct shape (unit test). + """ + mock_profile = Mock() + mock_profile.id = "00000000-0000-0000-0000-000000000001" + mock_profile.slug = "mock-profile" + mock_profile.description = "Mock" + mock_profile.config = {} + mock_list.return_value = [mock_profile] + + factory = APIRequestFactory() + request = factory.get("/openedx-ai-extensions/v1/profiles/", {"context": "{}"}) + request.user = user + + view = AIWorkflowProfilesListView.as_view() + response = view(request) + response.render() + + assert response.status_code == 200 + data = json.loads(response.content) + assert data["count"] == 1 + assert len(data["profiles"]) == 1 + assert "timestamp" in data + + +@pytest.mark.django_db +@patch("openedx_ai_extensions.api.v1.workflows.views.AIWorkflowScope.list_profiles_for_context") +def test_profiles_list_view_invalid_context_json_unit(mock_list, user): # pylint: disable=redefined-outer-name + """ + AIWorkflowProfilesListView returns 500 for invalid JSON context (unit test). + """ + mock_list.return_value = [] + + factory = APIRequestFactory() + request = factory.get( + "/openedx-ai-extensions/v1/profiles/", {"context": "invalid json{"} + ) + request.user = user + + view = AIWorkflowProfilesListView.as_view() + response = view(request) + response.render() + + assert response.status_code == 500 From 2d54a9b3f6741c27b2b468435ef40d27dcf22a30 Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Tue, 24 Mar 2026 21:09:44 -0500 Subject: [PATCH 02/13] feat: adding a UI to show the list of profiles Co-Authored-By: Claude Sonnet 4.6 --- .../components/WorkflowsConfigTab.tsx | 176 +++++++++++++++++- .../src/ai-extensions-settings/messages.ts | 27 +++ frontend/src/constants.ts | 1 + frontend/src/services/index.ts | 4 + frontend/src/services/profilesService.ts | 35 ++++ frontend/src/types.ts | 13 ++ 6 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 frontend/src/services/profilesService.ts diff --git a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx index a4f76285..105a4bf1 100644 --- a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx +++ b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx @@ -1,20 +1,184 @@ /** * WorkflowsConfigTab Component - * Placeholder tab for workflows configuration (to be developed) + * Two-column layout: profile list on the left, pretty-printed JSON on the right. + * + * NOTE: syntax highlighting via CodeMirror is deferred until the module boundary + * between this plugin and frontend-app-authoring is formalised (webpack alias needed + * to avoid duplicate @codemirror/state instances). */ +import { useEffect, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Alert } from '@openedx/paragon'; +import { Alert, Badge, Spinner } from '@openedx/paragon'; +import { AIWorkflowProfile } from '../../types'; +import { fetchProfilesList } from '../../services/profilesService'; +import { prepareContextData } from '../../services/utils'; import messages from '../messages'; +const COLUMN_HEADER_HEIGHT = '2.75rem'; + +/** Read-only JSON viewer. Plain pre for now; CodeMirror deferred (see file note). */ +const JsonViewer = ({ content }: { content: string }) => ( +
{content}
+); + +interface ProfileListItemProps { + profile: AIWorkflowProfile; + isSelected: boolean; + onSelect: (p: AIWorkflowProfile) => void; +} + +const ProfileListItem = ({ profile, isSelected, onSelect }: ProfileListItemProps) => { + const [hovered, setHovered] = useState(false); + + return ( + + ); +}; + const WorkflowsConfigTab = () => { const intl = useIntl(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [profiles, setProfiles] = useState([]); + const [selected, setSelected] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + const contextData = prepareContextData({}); + + fetchProfilesList({ contextData, signal: controller.signal }) + .then((data) => { + setProfiles(data.profiles); + if (data.profiles.length > 0) { setSelected(data.profiles[0]); } + setLoading(false); + }) + .catch((err) => { + if (err?.name === 'CanceledError' || err?.name === 'AbortError') { return; } + setError(intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.profiles.error'])); + setLoading(false); + }); + + return () => controller.abort(); + }, []); + + if (loading) { + return ( +
+ + + {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.profiles.loading'])} + +
+ ); + } + + if (error) { + return
{error}
; + } + + if (profiles.length === 0) { + return ( +
+ + {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.profiles.empty'])} + +
+ ); + } + + const configJson = selected ? JSON.stringify(selected.effectiveConfig, null, 2) : ''; return ( -
- - {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.placeholder'])} - +
+ + {/* Left column */} +
+
+ + {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.profiles.title'])} + + {profiles.length} +
+ +
+ {profiles.map((profile) => ( + + ))} +
+
+ + {/* Right column */} +
+ {selected && ( + <> +
+ {selected.slug} + Β· + + {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.profiles.config-label'])} + +
+
+ +
+ + )} +
+
); }; diff --git a/frontend/src/ai-extensions-settings/messages.ts b/frontend/src/ai-extensions-settings/messages.ts index 13be5274..9cec0f28 100644 --- a/frontend/src/ai-extensions-settings/messages.ts +++ b/frontend/src/ai-extensions-settings/messages.ts @@ -38,6 +38,33 @@ const messages = defineMessages({ defaultMessage: 'Workflows configuration is under development. This feature will allow you to configure AI-powered workflows for your course.', description: 'Placeholder text for workflows config tab', }, + + // Profiles list + 'openedx-ai-extensions.settings-modal.workflows.profiles.title': { + id: 'openedx-ai-extensions.settings-modal.workflows.profiles.title', + defaultMessage: 'Available AI Profiles', + description: 'Section title for the list of available AI profiles', + }, + 'openedx-ai-extensions.settings-modal.workflows.profiles.loading': { + id: 'openedx-ai-extensions.settings-modal.workflows.profiles.loading', + defaultMessage: 'Loading profiles...', + description: 'Loading message while fetching AI profiles', + }, + 'openedx-ai-extensions.settings-modal.workflows.profiles.empty': { + id: 'openedx-ai-extensions.settings-modal.workflows.profiles.empty', + defaultMessage: 'No AI profiles are configured for this course.', + description: 'Message shown when no profiles are found for the course', + }, + 'openedx-ai-extensions.settings-modal.workflows.profiles.error': { + id: 'openedx-ai-extensions.settings-modal.workflows.profiles.error', + defaultMessage: 'Failed to load profiles. Please try again.', + description: 'Error message shown when the profiles list request fails', + }, + 'openedx-ai-extensions.settings-modal.workflows.profiles.config-label': { + id: 'openedx-ai-extensions.settings-modal.workflows.profiles.config-label', + defaultMessage: 'Configuration', + description: 'Label for the effective configuration section of a profile card', + }, }); export default messages; diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index dbbe374e..4ba10e6e 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -11,6 +11,7 @@ export const DEFAULT_CHUNK_RATE_LIMIT_MS = 50; export const ENDPOINT_TYPES = { WORKFLOWS: 'workflows', PROFILE: 'profile', + LIST_PROFILES: 'profiles', } as const; export type EndpointType = typeof ENDPOINT_TYPES[keyof typeof ENDPOINT_TYPES]; diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index de4379eb..e09cfcb1 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -6,6 +6,10 @@ export { fetchConfiguration, } from './configService'; +export { + fetchProfilesList, +} from './profilesService'; + export { extractCourseIdFromUrl, extractLocationIdFromUrl, diff --git a/frontend/src/services/profilesService.ts b/frontend/src/services/profilesService.ts new file mode 100644 index 00000000..86cbc0d3 --- /dev/null +++ b/frontend/src/services/profilesService.ts @@ -0,0 +1,35 @@ +/** + * Profiles Service + * Handles fetching the list of AI Workflow Profiles available for a given course context + */ +import { camelCaseObject } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { PluginContext, ProfilesListResponse } from '../types'; +import { ENDPOINT_TYPES } from '../constants'; +import { getDefaultEndpoint } from './utils'; + +interface FetchProfilesListParams { + contextData: PluginContext; + signal?: AbortSignal | null; +} + +/** + * Fetch all AI Workflow Profiles available for the given course context. + * Omitting uiSlotSelectorId returns profiles for all slots β€” the intended + * pattern for the Studio settings panel. + */ +export const fetchProfilesList = async ({ + contextData, + signal = null, +}: FetchProfilesListParams): Promise => { + const endpoint = getDefaultEndpoint(ENDPOINT_TYPES.LIST_PROFILES); + const params = new URLSearchParams(); + if (contextData) { + params.append('context', JSON.stringify(contextData)); + } + + const url = `${endpoint}?${params.toString()}`; + const client = getAuthenticatedHttpClient(); + const response = await client.get(url, { signal }); + return camelCaseObject(response.data) as ProfilesListResponse; +}; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e0df95b0..45ecd29c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -42,3 +42,16 @@ export interface AIChatMessage extends Message { export interface AIModelResponse extends Message { role: 'user' | 'assistant'; } + +export interface AIWorkflowProfile { + id: string; + slug: string; + description: string | null; + effectiveConfig: Record; +} + +export interface ProfilesListResponse { + profiles: AIWorkflowProfile[]; + count: number; + timestamp: string; +} From 9909628fc4f592f03ac4930ae925ad50136841b2 Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Tue, 24 Mar 2026 21:12:40 -0500 Subject: [PATCH 03/13] feat: adding scopes to each profile in the list Co-Authored-By: Claude Sonnet 4.6 --- .../api/v1/workflows/serializers.py | 39 +++++++++++++++++-- .../openedx_ai_extensions/workflows/models.py | 18 ++++++--- backend/tests/test_api.py | 16 ++++++++ 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/backend/openedx_ai_extensions/api/v1/workflows/serializers.py b/backend/openedx_ai_extensions/api/v1/workflows/serializers.py index db55340f..b91ce3a2 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/serializers.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/serializers.py @@ -79,26 +79,57 @@ def update(self, instance, validated_data): raise NotImplementedError("AIWorkflowProfileSerializer is read-only") +class AIWorkflowScopeSerializer(serializers.Serializer): + """ + Serializer for an AIWorkflowScope instance in the profiles list endpoint. + + Exposes the routing fields that caused a scope to match the request context. + """ + + id = serializers.UUIDField(read_only=True) + course_id = serializers.CharField(allow_null=True, read_only=True) + service_variant = serializers.CharField(read_only=True) + enabled = serializers.BooleanField(read_only=True) + ui_slot_selector_id = serializers.CharField(read_only=True) + location_regex = serializers.CharField(allow_null=True, read_only=True) + specificity_index = serializers.IntegerField(read_only=True) + + def create(self, validated_data): + """Read-only serializer β€” creation not supported.""" + raise NotImplementedError("AIWorkflowScopeSerializer is read-only") + + def update(self, instance, validated_data): + """Read-only serializer β€” update not supported.""" + raise NotImplementedError("AIWorkflowScopeSerializer is read-only") + + class AIWorkflowProfileListSerializer(serializers.Serializer): """ Serializer for a single AIWorkflowProfile in the profiles list endpoint. - Exposes the profile's identity fields and its complete effective - configuration with sensitive values redacted. Designed to be extended - in future iterations to expose globally-configured provider information - alongside profile-level settings. + Exposes the profile's identity fields, its complete effective configuration + with sensitive values redacted, and the list of scopes that link to it in + the current request context. Designed to be extended in future iterations + to expose globally-configured provider information alongside profile-level + settings. """ id = serializers.UUIDField(read_only=True) slug = serializers.SlugField(read_only=True) description = serializers.CharField(allow_null=True, read_only=True) effective_config = serializers.SerializerMethodField() + scopes = serializers.SerializerMethodField() def get_effective_config(self, obj): """Return effective config with sensitive values redacted.""" config = obj.config or {} return redact_sensitive_config(config) + def get_scopes(self, obj): + """Return all scopes that matched this profile in the request context.""" + matched_scopes = getattr(obj, "matched_scopes", []) + return AIWorkflowScopeSerializer(matched_scopes, many=True).data + def create(self, validated_data): """Read-only serializer β€” creation not supported.""" raise NotImplementedError("AIWorkflowProfileListSerializer is read-only") diff --git a/backend/openedx_ai_extensions/workflows/models.py b/backend/openedx_ai_extensions/workflows/models.py index de21aaba..64ea81af 100644 --- a/backend/openedx_ai_extensions/workflows/models.py +++ b/backend/openedx_ai_extensions/workflows/models.py @@ -331,6 +331,8 @@ def list_profiles_for_context( Returns: list[AIWorkflowProfile]: Deduplicated list of matching profiles, ordered by descending specificity_index of the first matching scope per profile. + Each profile has a ``matched_scopes`` attribute containing all + ``AIWorkflowScope`` instances that linked to it in this context. """ course_filter = Q(course_id=course_id) | Q(course_id=CourseKeyField.Empty) base_filter = {"enabled": True} @@ -349,8 +351,8 @@ def list_profiles_for_context( **base_filter, ).select_related("profile").order_by("-specificity_index") - seen_profile_ids = set() - profiles = [] + # profile_id β†’ (profile, [matching scopes]) preserving insertion order + seen: dict = {} for scope in candidates: if scope.location_regex is None: @@ -366,9 +368,15 @@ def list_profiles_for_context( except re.error: continue - if scope.profile_id not in seen_profile_ids: - seen_profile_ids.add(scope.profile_id) - profiles.append(scope.profile) + if scope.profile_id not in seen: + seen[scope.profile_id] = (scope.profile, [scope]) + else: + seen[scope.profile_id][1].append(scope) + + profiles = [] + for profile, matched_scopes in seen.values(): + profile.matched_scopes = matched_scopes + profiles.append(profile) return profiles diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 8beb1fb4..8f539de0 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -773,6 +773,16 @@ def test_profiles_list_happy_path(api_client, course_key): # pylint: disable=re assert "slug" in profile assert "description" in profile assert "effective_config" in profile + assert "scopes" in profile + assert len(profile["scopes"]) == 1 + scope = profile["scopes"][0] + assert "id" in scope + assert "course_id" in scope + assert "service_variant" in scope + assert "enabled" in scope + assert "ui_slot_selector_id" in scope + assert "location_regex" in scope + assert "specificity_index" in scope @pytest.mark.django_db @@ -812,6 +822,7 @@ def test_profiles_list_api_keys_are_redacted( # pylint: disable=redefined-outer mock_profile.config = { "processor_config": {"LLMProcessor": {"options": {"api_key": "sk-secret-123"}}} } + mock_profile.matched_scopes = [] mock_list.return_value = [mock_profile] api_client.login(username="testuser", password="password123") @@ -856,6 +867,10 @@ def test_profiles_list_deduplication(api_client, course_key): # pylint: disable data = response.json() assert data["count"] == 1 assert data["profiles"][0]["slug"] == "pl-dedup" + # Both matching scopes must be included + assert len(data["profiles"][0]["scopes"]) == 2 + slot_ids = {s["ui_slot_selector_id"] for s in data["profiles"][0]["scopes"]} + assert slot_ids == {"slot-x", "slot-y"} @pytest.mark.django_db @@ -1060,6 +1075,7 @@ def test_profiles_list_view_returns_200_unit(mock_list, user): # pylint: disabl mock_profile.slug = "mock-profile" mock_profile.description = "Mock" mock_profile.config = {} + mock_profile.matched_scopes = [] mock_list.return_value = [mock_profile] factory = APIRequestFactory() From 984abb8aa3a2b1304ffbf828c09a47b156cbb44d Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Tue, 24 Mar 2026 21:26:32 -0500 Subject: [PATCH 04/13] feat: showing scopes in the UI Co-Authored-By: Claude Sonnet 4.6 --- .../components/WorkflowsConfigTab.tsx | 38 +++++++++++++++---- frontend/src/types.ts | 11 ++++++ 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx index 105a4bf1..d8556f49 100644 --- a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx +++ b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx @@ -9,8 +9,10 @@ import { useEffect, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Alert, Badge, Spinner } from '@openedx/paragon'; +import { Alert, Badge, Button, Spinner } from '@openedx/paragon'; import { AIWorkflowProfile } from '../../types'; + +type ProfileView = 'profile' | 'scopes' | 'prompt'; import { fetchProfilesList } from '../../services/profilesService'; import { prepareContextData } from '../../services/utils'; import messages from '../messages'; @@ -81,6 +83,12 @@ const WorkflowsConfigTab = () => { const [error, setError] = useState(null); const [profiles, setProfiles] = useState([]); const [selected, setSelected] = useState(null); + const [view, setView] = useState('profile'); + + const handleSelectProfile = (profile: AIWorkflowProfile) => { + setSelected(profile); + setView('profile'); + }; useEffect(() => { const controller = new AbortController(); @@ -127,6 +135,7 @@ const WorkflowsConfigTab = () => { } const configJson = selected ? JSON.stringify(selected.effectiveConfig, null, 2) : ''; + const scopesJson = selected ? JSON.stringify(selected.scopes, null, 2) : ''; return (
@@ -152,7 +161,7 @@ const WorkflowsConfigTab = () => { key={profile.id} profile={profile} isSelected={selected?.id === profile.id} - onSelect={setSelected} + onSelect={handleSelectProfile} /> ))}
@@ -166,14 +175,27 @@ const WorkflowsConfigTab = () => { className="bg-light border-bottom d-flex align-items-center px-4" style={{ height: COLUMN_HEADER_HEIGHT, flexShrink: 0, gap: '0.5rem' }} > - {selected.slug} - Β· - - {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.profiles.config-label'])} - + {selected.slug} + {(['profile', 'scopes', 'prompt'] as ProfileView[]).map((v) => ( + + ))}
- + {view === 'profile' && } + {view === 'scopes' && } + {view === 'prompt' && ( +
+ Prompt editing is not yet available. +
+ )}
)} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 45ecd29c..8dfd7b11 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -43,11 +43,22 @@ export interface AIModelResponse extends Message { role: 'user' | 'assistant'; } +export interface AIWorkflowScope { + id: string; + courseId: string | null; + serviceVariant: string | null; + enabled: boolean; + uiSlotSelectorId: string; + locationRegex: string | null; + specificityIndex: number; +} + export interface AIWorkflowProfile { id: string; slug: string; description: string | null; effectiveConfig: Record; + scopes: AIWorkflowScope[]; } export interface ProfilesListResponse { From 67f81df5b1149bd85e5204ab478c37013fb229cd Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Tue, 24 Mar 2026 22:28:30 -0500 Subject: [PATCH 05/13] feat: showing prompt templates in studio and allowing updates Co-Authored-By: Claude Sonnet 4.6 --- backend/openedx_ai_extensions/api/v1/urls.py | 8 +- .../api/v1/workflows/serializers.py | 50 +++++ .../api/v1/workflows/views.py | 92 +++++++- backend/tests/test_api.py | 209 ++++++++++++++++++ .../components/WorkflowsConfigTab.tsx | 194 ++++++++++++++-- frontend/src/services/index.ts | 2 + frontend/src/services/profilesService.ts | 41 +++- frontend/src/types.ts | 8 + 8 files changed, 569 insertions(+), 35 deletions(-) diff --git a/backend/openedx_ai_extensions/api/v1/urls.py b/backend/openedx_ai_extensions/api/v1/urls.py index bffbef00..cdd76ea8 100644 --- a/backend/openedx_ai_extensions/api/v1/urls.py +++ b/backend/openedx_ai_extensions/api/v1/urls.py @@ -4,7 +4,12 @@ from django.urls import path -from .workflows.views import AIGenericWorkflowView, AIWorkflowProfilesListView, AIWorkflowProfileView +from .workflows.views import ( + AIGenericWorkflowView, + AIWorkflowProfilesListView, + AIWorkflowProfileView, + PromptTemplateDetailView, +) app_name = "v1" @@ -12,4 +17,5 @@ path("workflows/", AIGenericWorkflowView.as_view(), name="aiext_workflows"), path("profile/", AIWorkflowProfileView.as_view(), name="aiext_ui_config"), path("profiles/", AIWorkflowProfilesListView.as_view(), name="aiext_profiles_list"), + path("prompts//", PromptTemplateDetailView.as_view(), name="aiext_prompt_detail"), ] diff --git a/backend/openedx_ai_extensions/api/v1/workflows/serializers.py b/backend/openedx_ai_extensions/api/v1/workflows/serializers.py index b91ce3a2..be081691 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/serializers.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/serializers.py @@ -6,6 +6,8 @@ from rest_framework import serializers +from openedx_ai_extensions.models import PromptTemplate + # Keys whose values must never be exposed to the frontend. _SENSITIVE_KEYS = frozenset({ "api_key", @@ -56,6 +58,54 @@ def _redact_node(node): return node +class PromptTemplateSerializer(serializers.Serializer): + """ + Serializer for a PromptTemplate instance. + + Exposes all public fields of the template. + """ + + id = serializers.UUIDField(read_only=True) + slug = serializers.SlugField(read_only=True) + body = serializers.CharField(read_only=True) + created_at = serializers.DateTimeField(read_only=True) + updated_at = serializers.DateTimeField(read_only=True) + + def create(self, validated_data): + """Read-only serializer β€” creation not supported.""" + raise NotImplementedError("PromptTemplateSerializer is read-only") + + def update(self, instance, validated_data): + """Read-only serializer β€” update not supported.""" + raise NotImplementedError("PromptTemplateSerializer is read-only") + + +class PromptTemplateUpdateSerializer(serializers.ModelSerializer): + """ + Write serializer for PromptTemplate β€” only ``body`` may be changed. + + Any field other than ``body`` in the request payload is rejected with a + validation error. ``created_at`` and ``updated_at`` are managed by Django + automatically and are never accepted as input. + """ + + class Meta: + """Serializer metadata.""" + + model = PromptTemplate + fields = ["body"] + + def validate(self, attrs): + """Reject any field not in the allowed set.""" + allowed = {"body"} + extra = set(self.initial_data.keys()) - allowed + if extra: + raise serializers.ValidationError( + {field: "This field cannot be changed." for field in extra} + ) + return super().validate(attrs) + + class AIWorkflowProfileSerializer(serializers.Serializer): """ Serializer for AIWorkflowProfile data diff --git a/backend/openedx_ai_extensions/api/v1/workflows/views.py b/backend/openedx_ai_extensions/api/v1/workflows/views.py index b9dc66c1..930b17bb 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/views.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/views.py @@ -15,15 +15,21 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from rest_framework import status -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from openedx_ai_extensions.decorators import handle_ai_errors +from openedx_ai_extensions.models import PromptTemplate from openedx_ai_extensions.utils import is_generator from openedx_ai_extensions.workflows.models import AIWorkflowScope -from .serializers import AIWorkflowProfileListSerializer, AIWorkflowProfileSerializer +from .serializers import ( + AIWorkflowProfileListSerializer, + AIWorkflowProfileSerializer, + PromptTemplateSerializer, + PromptTemplateUpdateSerializer, +) logger = logging.getLogger(__name__) @@ -244,3 +250,85 @@ def get(self, request): }, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + + +class PromptTemplateDetailView(APIView): + """ + API endpoint to retrieve a single PromptTemplate by slug or UUID. + + Accepts either form as the ``identifier`` URL segment: + ``GET /v1/prompts//`` + ``GET /v1/prompts//`` + """ + + # Staff-only for now. + # TODO: replace with fine-grained openedx-authz permission (course-staff / + # content-author) once the openedx-authz integration is in place. + permission_classes = [IsAdminUser] + + def _get_template(self, identifier): + """ + Look up a PromptTemplate by slug first, then by UUID. + + Args: + identifier (str): Slug or UUID string. + + Returns: + PromptTemplate or None + """ + try: + return PromptTemplate.objects.get(slug=identifier) + except PromptTemplate.DoesNotExist: + pass + try: + return PromptTemplate.objects.get(id=identifier) + except (PromptTemplate.DoesNotExist, Exception): # pylint: disable=broad-exception-caught + return None + + def get(self, request, identifier): + """ + Retrieve a prompt template by slug or UUID. + + Args: + identifier (str): Slug or UUID of the prompt template. + + Returns: + 200: Serialized prompt template. + 404: No template found for the given identifier. + """ + template = self._get_template(identifier) + if template is None: + return Response( + {"error": f"Prompt template '{identifier}' not found.", "status": "not_found"}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response(PromptTemplateSerializer(template).data, status=status.HTTP_200_OK) + + def patch(self, request, identifier): + """ + Update the body of a prompt template. + + Only the ``body`` field may be changed. Any other field in the request + payload is rejected with HTTP 400. + + Args: + identifier (str): Slug or UUID of the prompt template. + + Returns: + 200: Updated serialized prompt template. + 400: Payload contains fields other than ``body``, or body is blank. + 404: No template found for the given identifier. + """ + template = self._get_template(identifier) + if template is None: + return Response( + {"error": f"Prompt template '{identifier}' not found.", "status": "not_found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = PromptTemplateUpdateSerializer(template, data=request.data, partial=True) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + serializer.save() + return Response(PromptTemplateSerializer(template).data, status=status.HTTP_200_OK) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 8f539de0..56bc30d3 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -20,12 +20,14 @@ from openedx_ai_extensions.api.v1.workflows.serializers import ( # noqa: E402 pylint: disable=wrong-import-position AIWorkflowProfileListSerializer, AIWorkflowProfileSerializer, + PromptTemplateSerializer, redact_sensitive_config, ) from openedx_ai_extensions.api.v1.workflows.views import ( # noqa: E402 pylint: disable=wrong-import-position AIWorkflowProfilesListView, AIWorkflowProfileView, ) +from openedx_ai_extensions.models import PromptTemplate # noqa: E402 pylint: disable=wrong-import-position from openedx_ai_extensions.workflows.models import ( # noqa: E402 pylint: disable=wrong-import-position AIWorkflowProfile, AIWorkflowScope, @@ -1112,3 +1114,210 @@ def test_profiles_list_view_invalid_context_json_unit(mock_list, user): # pylin response.render() assert response.status_code == 500 + + +# ============================================================================ +# Tests - Prompt Template Detail Endpoint (GET /v1/prompts//) +# ============================================================================ + + +@pytest.fixture +def prompt_template(db): # pylint: disable=unused-argument + """Create a PromptTemplate for tests.""" + return PromptTemplate.objects.create( + slug="test-prompt", + body="You are a helpful assistant. Explain: {{ topic }}", + ) + + +@pytest.mark.django_db +def test_prompt_detail_url_is_registered(): + """Prompt detail URL resolves for both slug and uuid patterns.""" + url = reverse("openedx_ai_extensions:api:v1:aiext_prompt_detail", kwargs={"identifier": "my-slug"}) + assert url == "/openedx-ai-extensions/v1/prompts/my-slug/" + + +@pytest.mark.django_db +def test_prompt_detail_requires_authentication(api_client, prompt_template): # pylint: disable=redefined-outer-name + """Unauthenticated requests are rejected.""" + url = reverse( + "openedx_ai_extensions:api:v1:aiext_prompt_detail", + kwargs={"identifier": prompt_template.slug}, + ) + response = api_client.get(url) + assert response.status_code in [401, 403] + + +@pytest.mark.django_db +@pytest.mark.usefixtures("user") +def test_prompt_detail_requires_staff(api_client, prompt_template): # pylint: disable=redefined-outer-name + """Non-staff authenticated users are rejected.""" + api_client.login(username="testuser", password="password123") + url = reverse( + "openedx_ai_extensions:api:v1:aiext_prompt_detail", + kwargs={"identifier": prompt_template.slug}, + ) + response = api_client.get(url) + assert response.status_code == 403 + + +@pytest.mark.django_db +@pytest.mark.usefixtures("staff_user") +def test_prompt_detail_by_slug(api_client, prompt_template): # pylint: disable=redefined-outer-name + """Fetching a prompt by slug returns 200 with all fields.""" + api_client.login(username="staffuser", password="password123") + url = reverse( + "openedx_ai_extensions:api:v1:aiext_prompt_detail", + kwargs={"identifier": prompt_template.slug}, + ) + response = api_client.get(url) + + assert response.status_code == 200 + data = response.json() + assert data["slug"] == prompt_template.slug + assert data["body"] == prompt_template.body + assert str(data["id"]) == str(prompt_template.id) + assert "created_at" in data + assert "updated_at" in data + + +@pytest.mark.django_db +@pytest.mark.usefixtures("staff_user") +def test_prompt_detail_by_uuid(api_client, prompt_template): # pylint: disable=redefined-outer-name + """Fetching a prompt by UUID returns the same template.""" + api_client.login(username="staffuser", password="password123") + url = reverse( + "openedx_ai_extensions:api:v1:aiext_prompt_detail", + kwargs={"identifier": str(prompt_template.id)}, + ) + response = api_client.get(url) + + assert response.status_code == 200 + data = response.json() + assert data["slug"] == prompt_template.slug + assert str(data["id"]) == str(prompt_template.id) + + +@pytest.mark.django_db +@pytest.mark.usefixtures("staff_user") +def test_prompt_detail_not_found(api_client): # pylint: disable=redefined-outer-name + """Unknown identifier returns 404 with a meaningful error.""" + api_client.login(username="staffuser", password="password123") + url = reverse( + "openedx_ai_extensions:api:v1:aiext_prompt_detail", + kwargs={"identifier": "does-not-exist"}, + ) + response = api_client.get(url) + + assert response.status_code == 404 + data = response.json() + assert "error" in data + assert data["status"] == "not_found" + + +@pytest.mark.django_db +@pytest.mark.usefixtures("staff_user") +def test_prompt_detail_unknown_uuid(api_client): # pylint: disable=redefined-outer-name + """A well-formed UUID that matches no record returns 404.""" + api_client.login(username="staffuser", password="password123") + url = reverse( + "openedx_ai_extensions:api:v1:aiext_prompt_detail", + kwargs={"identifier": "00000000-0000-0000-0000-000000000000"}, + ) + response = api_client.get(url) + + assert response.status_code == 404 + + +@pytest.mark.django_db +@pytest.mark.usefixtures("staff_user") +def test_prompt_patch_updates_body(api_client, prompt_template): # pylint: disable=redefined-outer-name + """PATCH with only body updates the template and returns the full representation.""" + api_client.login(username="staffuser", password="password123") + url = reverse( + "openedx_ai_extensions:api:v1:aiext_prompt_detail", + kwargs={"identifier": prompt_template.slug}, + ) + response = api_client.patch(url, {"body": "New prompt text."}, format="json") + + assert response.status_code == 200 + data = response.json() + assert data["body"] == "New prompt text." + prompt_template.refresh_from_db() + assert prompt_template.body == "New prompt text." + + +@pytest.mark.django_db +@pytest.mark.usefixtures("staff_user") +def test_prompt_patch_rejects_extra_fields(api_client, prompt_template): # pylint: disable=redefined-outer-name + """PATCH with any field besides body is rejected with 400.""" + api_client.login(username="staffuser", password="password123") + url = reverse( + "openedx_ai_extensions:api:v1:aiext_prompt_detail", + kwargs={"identifier": prompt_template.slug}, + ) + response = api_client.patch( + url, {"body": "ok", "slug": "hacked"}, format="json" + ) + + assert response.status_code == 400 + assert "slug" in response.json() + + +@pytest.mark.django_db +@pytest.mark.usefixtures("staff_user") +def test_prompt_patch_rejects_id_change(api_client, prompt_template): # pylint: disable=redefined-outer-name + """PATCH attempting to change the id is rejected.""" + api_client.login(username="staffuser", password="password123") + url = reverse( + "openedx_ai_extensions:api:v1:aiext_prompt_detail", + kwargs={"identifier": prompt_template.slug}, + ) + response = api_client.patch( + url, {"id": "00000000-0000-0000-0000-000000000000"}, format="json" + ) + + assert response.status_code == 400 + + +@pytest.mark.django_db +@pytest.mark.usefixtures("staff_user") +def test_prompt_patch_not_found(api_client): # pylint: disable=redefined-outer-name + """PATCH on a non-existent identifier returns 404.""" + api_client.login(username="staffuser", password="password123") + url = reverse( + "openedx_ai_extensions:api:v1:aiext_prompt_detail", + kwargs={"identifier": "no-such-template"}, + ) + response = api_client.patch(url, {"body": "irrelevant"}, format="json") + + assert response.status_code == 404 + + +@pytest.mark.django_db +@pytest.mark.usefixtures("user") +def test_prompt_patch_requires_staff(api_client, prompt_template): # pylint: disable=redefined-outer-name + """Non-staff users cannot PATCH.""" + api_client.login(username="testuser", password="password123") + url = reverse( + "openedx_ai_extensions:api:v1:aiext_prompt_detail", + kwargs={"identifier": prompt_template.slug}, + ) + response = api_client.patch(url, {"body": "attempt"}, format="json") + + assert response.status_code == 403 + + +# ============================================================================ +# Unit Tests - PromptTemplateSerializer +# ============================================================================ + + +@pytest.mark.django_db +def test_prompt_template_serializer_read_only(prompt_template): # pylint: disable=redefined-outer-name + """PromptTemplateDetailView is read-only.""" + serializer = PromptTemplateSerializer(prompt_template) + with pytest.raises(NotImplementedError): + serializer.create({}) + with pytest.raises(NotImplementedError): + serializer.update(prompt_template, {}) diff --git a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx index d8556f49..114f0520 100644 --- a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx +++ b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx @@ -1,6 +1,6 @@ /** * WorkflowsConfigTab Component - * Two-column layout: profile list on the left, pretty-printed JSON on the right. + * Two-column layout: profile list on the left, detail view on the right. * * NOTE: syntax highlighting via CodeMirror is deferred until the module boundary * between this plugin and frontend-app-authoring is formalised (webpack alias needed @@ -9,17 +9,127 @@ import { useEffect, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Alert, Badge, Button, Spinner } from '@openedx/paragon'; -import { AIWorkflowProfile } from '../../types'; - -type ProfileView = 'profile' | 'scopes' | 'prompt'; -import { fetchProfilesList } from '../../services/profilesService'; +import { + Alert, Badge, Button, Form, OverlayTrigger, Spinner, Tooltip, +} from '@openedx/paragon'; +import { AIWorkflowProfile, PromptTemplate } from '../../types'; +import { + fetchProfilesList, fetchPromptTemplate, savePromptTemplate, +} from '../../services/profilesService'; import { prepareContextData } from '../../services/utils'; import messages from '../messages'; +type ProfileView = 'profile' | 'scopes' | 'prompt'; + const COLUMN_HEADER_HEIGHT = '2.75rem'; -/** Read-only JSON viewer. Plain pre for now; CodeMirror deferred (see file note). */ +/** Scan processorConfig for the first promptTemplate value, returns null if absent. */ +const getPromptTemplate = (effectiveConfig: Record): string | null => { + const processorConfig = effectiveConfig?.processorConfig ?? effectiveConfig?.processor_config; + if (!processorConfig || typeof processorConfig !== 'object') { return null; } + for (const processor of Object.values(processorConfig)) { + const template = (processor as any)?.promptTemplate ?? (processor as any)?.prompt_template; + if (template) { return String(template); } + } + return null; +}; + +/** Relative time label with full date on hover. */ +const RelativeDate = ({ dateStr }: { dateStr: string }) => { + const date = new Date(dateStr); + const diffMs = Date.now() - date.getTime(); + const diffSeconds = Math.round(diffMs / 1000); + + let value: number; + let unit: Intl.RelativeTimeFormatUnit; + if (diffSeconds < 60) { value = -diffSeconds; unit = 'second'; } else if (diffSeconds < 3600) { value = -Math.round(diffSeconds / 60); unit = 'minute'; } else if (diffSeconds < 86400) { value = -Math.round(diffSeconds / 3600); unit = 'hour'; } else if (diffSeconds < 2592000) { value = -Math.round(diffSeconds / 86400); unit = 'day'; } else if (diffSeconds < 31536000) { value = -Math.round(diffSeconds / 2592000); unit = 'month'; } else { value = -Math.round(diffSeconds / 31536000); unit = 'year'; } + + const relative = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(value, unit); + const full = date.toLocaleString(); + + return ( + {full}}> + {relative} + + ); +}; + +/** Structured editor for a PromptTemplate. */ +const PromptView = ({ data, identifier }: { data: PromptTemplate; identifier: string }) => { + const [body, setBody] = useState(data.body); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [saved, setSaved] = useState(false); + + const isDirty = body !== data.body; + + const handleSave = async () => { + setSaving(true); + setSaveError(null); + try { + await savePromptTemplate({ identifier, body }); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch { + setSaveError('Failed to save prompt. Please try again.'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ + ID + + + + Slug + + +
+ +
+
+ Created + +
+
+ Updated + +
+
+ + + Prompt body + ) => setBody(e.target.value)} + style={{ resize: 'vertical', lineHeight: 1.6 }} + /> + + + {saveError && {saveError}} + +
+ {saved && Saved!} + +
+
+ ); +}; + +/** Read-only JSON viewer. */ const JsonViewer = ({ content }: { content: string }) => (
 (
       overflowY: 'auto',
       whiteSpace: 'pre',
     }}
-  >{content}
+ >{content} + ); interface ProfileListItemProps { @@ -56,9 +167,7 @@ const ProfileListItem = ({ profile, isSelected, onSelect }: ProfileListItemProps transition: 'background 0.1s', }} > -
- {profile.slug} -
+
{profile.slug}
{profile.description && (
{ const [profiles, setProfiles] = useState([]); const [selected, setSelected] = useState(null); const [view, setView] = useState('profile'); + const [promptData, setPromptData] = useState(null); + const [promptLoading, setPromptLoading] = useState(false); + const [promptError, setPromptError] = useState(null); const handleSelectProfile = (profile: AIWorkflowProfile) => { setSelected(profile); setView('profile'); + setPromptData(null); + setPromptError(null); }; useEffect(() => { const controller = new AbortController(); - const contextData = prepareContextData({}); - - fetchProfilesList({ contextData, signal: controller.signal }) + fetchProfilesList({ contextData: prepareContextData({}), signal: controller.signal }) .then((data) => { setProfiles(data.profiles); if (data.profiles.length > 0) { setSelected(data.profiles[0]); } @@ -105,9 +217,28 @@ const WorkflowsConfigTab = () => { setError(intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.profiles.error'])); setLoading(false); }); + return () => controller.abort(); + }, [intl]); + + useEffect(() => { + if (view !== 'prompt' || !selected) { return undefined; } + const identifier = getPromptTemplate(selected.effectiveConfig); + if (!identifier) { return undefined; } + + const controller = new AbortController(); + setPromptLoading(true); + setPromptError(null); + + fetchPromptTemplate({ identifier, signal: controller.signal }) + .then((data) => { setPromptData(data); setPromptLoading(false); }) + .catch((err) => { + if (err?.name === 'CanceledError' || err?.name === 'AbortError') { return; } + setPromptError(intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.profiles.error'])); + setPromptLoading(false); + }); return () => controller.abort(); - }, []); + }, [view, selected, intl]); if (loading) { return ( @@ -136,6 +267,25 @@ const WorkflowsConfigTab = () => { const configJson = selected ? JSON.stringify(selected.effectiveConfig, null, 2) : ''; const scopesJson = selected ? JSON.stringify(selected.scopes, null, 2) : ''; + const promptTemplate = selected ? getPromptTemplate(selected.effectiveConfig) : null; + + const renderPromptContent = () => { + if (promptLoading) { + return ( +
+ + Loading prompt… +
+ ); + } + if (promptError) { + return
{promptError}
; + } + if (promptData) { + return ; + } + return null; + }; return (
@@ -154,7 +304,6 @@ const WorkflowsConfigTab = () => { {profiles.length}
-
{profiles.map((profile) => ( { variant={view === v ? 'primary' : 'tertiary'} onClick={() => setView(v)} className="text-capitalize" + disabled={v === 'prompt' && !promptTemplate} > {v} ))}
-
+ +
{view === 'profile' && } {view === 'scopes' && } - {view === 'prompt' && ( -
- Prompt editing is not yet available. -
- )} + {view === 'prompt' && renderPromptContent()}
)} diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index e09cfcb1..8f9d44da 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -8,6 +8,8 @@ export { export { fetchProfilesList, + fetchPromptTemplate, + savePromptTemplate, } from './profilesService'; export { diff --git a/frontend/src/services/profilesService.ts b/frontend/src/services/profilesService.ts index 86cbc0d3..6c114976 100644 --- a/frontend/src/services/profilesService.ts +++ b/frontend/src/services/profilesService.ts @@ -1,10 +1,11 @@ /** * Profiles Service - * Handles fetching the list of AI Workflow Profiles available for a given course context + * Handles fetching the list of AI Workflow Profiles available for a given course context, + * and fetching/saving individual prompt templates. */ import { camelCaseObject } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { PluginContext, ProfilesListResponse } from '../types'; +import { PluginContext, ProfilesListResponse, PromptTemplate } from '../types'; import { ENDPOINT_TYPES } from '../constants'; import { getDefaultEndpoint } from './utils'; @@ -13,11 +14,6 @@ interface FetchProfilesListParams { signal?: AbortSignal | null; } -/** - * Fetch all AI Workflow Profiles available for the given course context. - * Omitting uiSlotSelectorId returns profiles for all slots β€” the intended - * pattern for the Studio settings panel. - */ export const fetchProfilesList = async ({ contextData, signal = null, @@ -27,9 +23,34 @@ export const fetchProfilesList = async ({ if (contextData) { params.append('context', JSON.stringify(contextData)); } - const url = `${endpoint}?${params.toString()}`; - const client = getAuthenticatedHttpClient(); - const response = await client.get(url, { signal }); + const response = await getAuthenticatedHttpClient().get(url, { signal }); return camelCaseObject(response.data) as ProfilesListResponse; }; + +const getPromptUrl = (identifier: string): string => { + const base = getDefaultEndpoint('prompts' as any).replace(/\/$/, ''); + return `${base}/${identifier}/`; +}; + +export const fetchPromptTemplate = async ({ + identifier, + signal = null, +}: { + identifier: string; + signal?: AbortSignal | null; +}): Promise => { + const response = await getAuthenticatedHttpClient().get(getPromptUrl(identifier), { signal }); + return camelCaseObject(response.data) as PromptTemplate; +}; + +export const savePromptTemplate = async ({ + identifier, + body, +}: { + identifier: string; + body: string; +}): Promise => { + const response = await getAuthenticatedHttpClient().patch(getPromptUrl(identifier), { body }); + return camelCaseObject(response.data) as PromptTemplate; +}; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8dfd7b11..4dd0572e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -43,6 +43,14 @@ export interface AIModelResponse extends Message { role: 'user' | 'assistant'; } +export interface PromptTemplate { + id: string; + slug: string; + body: string; + createdAt: string; + updatedAt: string; +} + export interface AIWorkflowScope { id: string; courseId: string | null; From e202981e3633ff704ba81f223df61f636a8604cc Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Wed, 6 May 2026 18:11:22 -0500 Subject: [PATCH 06/13] feat: moving permission checks to its own file and adding granularity using authz Co-Authored-By: Claude Sonnet 4.6 --- .../api/v1/workflows/permissions.py | 119 ++++++++++++++++++ .../api/v1/workflows/views.py | 71 ++--------- .../src/ai-extensions-settings/messages.ts | 2 +- 3 files changed, 127 insertions(+), 65 deletions(-) create mode 100644 backend/openedx_ai_extensions/api/v1/workflows/permissions.py diff --git a/backend/openedx_ai_extensions/api/v1/workflows/permissions.py b/backend/openedx_ai_extensions/api/v1/workflows/permissions.py new file mode 100644 index 00000000..bd624c6d --- /dev/null +++ b/backend/openedx_ai_extensions/api/v1/workflows/permissions.py @@ -0,0 +1,119 @@ +""" +DRF permission classes and shared request utilities for AI Workflows API. +""" + +import json + +from django.core.exceptions import ValidationError +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey, UsageKey +from rest_framework.permissions import BasePermission + +try: + from openedx_authz import api as authz_api +except ImportError: + authz_api = None + +_COURSE_ADVANCED_SETTINGS_ACTION = "courses.manage_advanced_settings" + + +def get_context_from_request(request): + """ + Extract and validate context from request query parameters. + + Validates course_id and location_id formats using Open edX opaque_keys. + Returns a dict with snake_case keys. + + Args: + request: Django request object with query parameters + + Returns: + dict: Context with validated course_id and location_id in snake_case + + Raises: + ValidationError: If course_id or location_id are invalid + """ + if hasattr(request, "GET"): + context_str = request.GET.get("context", "{}") + else: + context_str = request.query_params.get("context", "{}") + + try: + context = json.loads(context_str) + except json.JSONDecodeError as e: + raise ValidationError("Invalid JSON format in 'context' parameter.") from e + validated_context = {} + + # Validate and convert courseId to course_id + course_id_raw = context.get("courseId") or context.get("course_id") + if course_id_raw: + try: + CourseKey.from_string(course_id_raw) + validated_context["course_id"] = course_id_raw + except InvalidKeyError as e: + raise ValidationError(f"Invalid course_id format: {course_id_raw}") from e + + # Validate and convert locationId to location_id + location_id_raw = context.get("locationId") or context.get("location_id") + if location_id_raw: + try: + UsageKey.from_string(location_id_raw) + validated_context["location_id"] = location_id_raw + except InvalidKeyError as e: + raise ValidationError(f"Invalid location_id format: {location_id_raw}") from e + + # Pass ui_slot_selector_id as-is (plain string, no special validation needed) + ui_slot_selector_id_raw = context.get("uiSlotSelectorId") or context.get("ui_slot_selector_id") + if ui_slot_selector_id_raw: + validated_context["ui_slot_selector_id"] = str(ui_slot_selector_id_raw) + + return validated_context + + +class CourseAdvancedSettingsPermission(BasePermission): + """ + Restricts access to users who are authorised to manage advanced settings + for a course β€” roughly equivalent to the course instructor/admin role. + + This permission is intentionally written to be forward-compatible with the + openedx-authz RBAC system introduced in Ulmo. The behaviour differs by + platform, but the intent is the same on both: + + * **Teak and earlier** (openedx-authz not installed): falls back to + Django's ``is_staff`` flag, which is the coarsest available gate on + platforms that do not yet ship openedx-authz. + + * **Ulmo and later** (openedx-authz installed): enforces + ``courses.manage_advanced_settings`` via the Casbin policy engine, + scoped to the course identified by the ``context`` query param. + Staff and superusers are always allowed regardless of policy. + Requests without a valid ``course_id`` in context are denied. + """ + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + + if authz_api is None: + return bool(request.user.is_staff) + + if request.user.is_staff or request.user.is_superuser: + return True + + try: + context = get_context_from_request(request) + except ValidationError: + return False + + course_id = context.get("course_id") + if not course_id: + return False + + try: + return authz_api.is_user_allowed( + request.user.username, + _COURSE_ADVANCED_SETTINGS_ACTION, + course_id, + ) + except Exception: # pylint: disable=broad-exception-caught + return False diff --git a/backend/openedx_ai_extensions/api/v1/workflows/views.py b/backend/openedx_ai_extensions/api/v1/workflows/views.py index 930b17bb..2b9ea2b3 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/views.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/views.py @@ -12,13 +12,15 @@ from django.http import JsonResponse, StreamingHttpResponse from django.utils.decorators import method_decorator from django.views import View -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey, UsageKey from rest_framework import status -from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView +from openedx_ai_extensions.api.v1.workflows.permissions import ( + CourseAdvancedSettingsPermission, + get_context_from_request, +) from openedx_ai_extensions.decorators import handle_ai_errors from openedx_ai_extensions.models import PromptTemplate from openedx_ai_extensions.utils import is_generator @@ -34,59 +36,6 @@ logger = logging.getLogger(__name__) -def get_context_from_request(request): - """ - Extract and validate context from request query parameters. - - Validates course_id and location_id formats using Open edX opaque_keys. - Returns a dict with snake_case keys. - - Args: - request: Django request object with query parameters - - Returns: - dict: Context with validated course_id and location_id in snake_case - - Raises: - ValidationError: If course_id or location_id are invalid - """ - if hasattr(request, "GET"): - context_str = request.GET.get("context", "{}") - else: - context_str = request.query_params.get("context", "{}") - - try: - context = json.loads(context_str) - except json.JSONDecodeError as e: - raise ValidationError("Invalid JSON format in 'context' parameter.") from e - validated_context = {} - - # Validate and convert courseId to course_id - course_id_raw = context.get("courseId") or context.get("course_id") - if course_id_raw: - try: - CourseKey.from_string(course_id_raw) - validated_context["course_id"] = course_id_raw - except InvalidKeyError as e: - raise ValidationError(f"Invalid course_id format: {course_id_raw}") from e - - # Validate and convert locationId to location_id - location_id_raw = context.get("locationId") or context.get("location_id") - if location_id_raw: - try: - UsageKey.from_string(location_id_raw) - validated_context["location_id"] = location_id_raw - except InvalidKeyError as e: - raise ValidationError(f"Invalid location_id format: {location_id_raw}") from e - - # Pass ui_slot_selector_id as-is (plain string, no special validation needed) - ui_slot_selector_id_raw = context.get("uiSlotSelectorId") or context.get("ui_slot_selector_id") - if ui_slot_selector_id_raw: - validated_context["ui_slot_selector_id"] = str(ui_slot_selector_id_raw) - - return validated_context - - @method_decorator(login_required, name="dispatch") @method_decorator(handle_ai_errors, name="dispatch") class AIGenericWorkflowView(View): @@ -186,10 +135,7 @@ class AIWorkflowProfilesListView(APIView): β€” the intended call pattern for the Studio settings panel. """ - # TODO: elevate to course-staff permission once the edxapp_wrapper provides a - # has_course_author_access integration. Requires common.djangoapps.student.roles - # (edx-platform) which is not a standalone dependency of this plugin. - permission_classes = [IsAuthenticated] + permission_classes = [CourseAdvancedSettingsPermission] def get(self, request): """ @@ -261,10 +207,7 @@ class PromptTemplateDetailView(APIView): ``GET /v1/prompts//`` """ - # Staff-only for now. - # TODO: replace with fine-grained openedx-authz permission (course-staff / - # content-author) once the openedx-authz integration is in place. - permission_classes = [IsAdminUser] + permission_classes = [CourseAdvancedSettingsPermission] def _get_template(self, identifier): """ diff --git a/frontend/src/ai-extensions-settings/messages.ts b/frontend/src/ai-extensions-settings/messages.ts index 9cec0f28..81386b76 100644 --- a/frontend/src/ai-extensions-settings/messages.ts +++ b/frontend/src/ai-extensions-settings/messages.ts @@ -57,7 +57,7 @@ const messages = defineMessages({ }, 'openedx-ai-extensions.settings-modal.workflows.profiles.error': { id: 'openedx-ai-extensions.settings-modal.workflows.profiles.error', - defaultMessage: 'Failed to load profiles. Please try again.', + defaultMessage: 'Failed to load profiles. Please check your permissions and try again.', description: 'Error message shown when the profiles list request fails', }, 'openedx-ai-extensions.settings-modal.workflows.profiles.config-label': { From 17c3d6292e213a095a36b0f3453277de880eddd3 Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Thu, 7 May 2026 12:48:56 -0500 Subject: [PATCH 07/13] feat: addressing feedback to not use authz, but courseacessrole instead Co-Authored-By: Claude Sonnet 4.6 --- .../api/v1/workflows/permissions.py | 50 ++++++------------- .../api/v1/workflows/views.py | 6 +-- .../backends/student_module_t_v1.py | 18 +++++++ .../backends/student_module_test.py | 5 ++ .../edxapp_wrapper/student_module.py | 10 ++++ .../openedx_ai_extensions/settings/common.py | 3 ++ backend/test_settings.py | 4 ++ backend/tests/test_api.py | 42 ++++++++-------- backend/tests/test_edxapp_wrapper.py | 40 +++++++++++++++ 9 files changed, 120 insertions(+), 58 deletions(-) create mode 100644 backend/openedx_ai_extensions/edxapp_wrapper/backends/student_module_t_v1.py create mode 100644 backend/openedx_ai_extensions/edxapp_wrapper/backends/student_module_test.py create mode 100644 backend/openedx_ai_extensions/edxapp_wrapper/student_module.py diff --git a/backend/openedx_ai_extensions/api/v1/workflows/permissions.py b/backend/openedx_ai_extensions/api/v1/workflows/permissions.py index bd624c6d..cf84088e 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/permissions.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/permissions.py @@ -3,18 +3,16 @@ """ import json +import logging from django.core.exceptions import ValidationError from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from rest_framework.permissions import BasePermission -try: - from openedx_authz import api as authz_api -except ImportError: - authz_api = None +from openedx_ai_extensions.edxapp_wrapper.student_module import permission_is_course_staff -_COURSE_ADVANCED_SETTINGS_ACTION = "courses.manage_advanced_settings" +logger = logging.getLogger(__name__) def get_context_from_request(request): @@ -70,50 +68,34 @@ def get_context_from_request(request): return validated_context -class CourseAdvancedSettingsPermission(BasePermission): +class CourseStaffPermission(BasePermission): """ Restricts access to users who are authorised to manage advanced settings - for a course β€” roughly equivalent to the course instructor/admin role. + for a course. - This permission is intentionally written to be forward-compatible with the - openedx-authz RBAC system introduced in Ulmo. The behaviour differs by - platform, but the intent is the same on both: - - * **Teak and earlier** (openedx-authz not installed): falls back to - Django's ``is_staff`` flag, which is the coarsest available gate on - platforms that do not yet ship openedx-authz. - - * **Ulmo and later** (openedx-authz installed): enforces - ``courses.manage_advanced_settings`` via the Casbin policy engine, - scoped to the course identified by the ``context`` query param. - Staff and superusers are always allowed regardless of policy. - Requests without a valid ``course_id`` in context are denied. + * Staff and superusers are always allowed. + * Otherwise, requires a valid ``course_id`` in the ``context`` query param + and delegates to the configured ``STUDENT_MODULE_BACKEND`` to check + whether the user holds a course-level staff or instructor role. """ def has_permission(self, request, view): - if not request.user or not request.user.is_authenticated: - return False + user = request.user - if authz_api is None: - return bool(request.user.is_staff) + if not user or not user.is_authenticated: + return False - if request.user.is_staff or request.user.is_superuser: + if user.is_staff or user.is_superuser: return True try: context = get_context_from_request(request) - except ValidationError: + except ValidationError as e: + logger.debug("CourseStaffPermission denied β€” invalid context: %s", e) return False course_id = context.get("course_id") if not course_id: return False - try: - return authz_api.is_user_allowed( - request.user.username, - _COURSE_ADVANCED_SETTINGS_ACTION, - course_id, - ) - except Exception: # pylint: disable=broad-exception-caught - return False + return permission_is_course_staff(user, course_id) diff --git a/backend/openedx_ai_extensions/api/v1/workflows/views.py b/backend/openedx_ai_extensions/api/v1/workflows/views.py index 2b9ea2b3..2a393161 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/views.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/views.py @@ -18,7 +18,7 @@ from rest_framework.views import APIView from openedx_ai_extensions.api.v1.workflows.permissions import ( - CourseAdvancedSettingsPermission, + CourseStaffPermission, get_context_from_request, ) from openedx_ai_extensions.decorators import handle_ai_errors @@ -135,7 +135,7 @@ class AIWorkflowProfilesListView(APIView): β€” the intended call pattern for the Studio settings panel. """ - permission_classes = [CourseAdvancedSettingsPermission] + permission_classes = [CourseStaffPermission] def get(self, request): """ @@ -207,7 +207,7 @@ class PromptTemplateDetailView(APIView): ``GET /v1/prompts//`` """ - permission_classes = [CourseAdvancedSettingsPermission] + permission_classes = [CourseStaffPermission] def _get_template(self, identifier): """ diff --git a/backend/openedx_ai_extensions/edxapp_wrapper/backends/student_module_t_v1.py b/backend/openedx_ai_extensions/edxapp_wrapper/backends/student_module_t_v1.py new file mode 100644 index 00000000..a66f8b6d --- /dev/null +++ b/backend/openedx_ai_extensions/edxapp_wrapper/backends/student_module_t_v1.py @@ -0,0 +1,18 @@ +""" Backend abstraction for Teak and later. """ +from common.djangoapps.student.models import CourseAccessRole # pylint: disable=import-error +from opaque_keys.edx.keys import CourseKey + +_COURSE_STAFF_ROLES = frozenset(["instructor"]) # higher than "staff" + + +def permission_is_course_staff(user, course_id): + """ Return True if user holds a staff or instructor role for course_id. """ + try: + course_key = CourseKey.from_string(course_id) + return CourseAccessRole.objects.filter( + user=user, + course_id=course_key, + role__in=_COURSE_STAFF_ROLES, + ).exists() + except Exception: # pylint: disable=broad-exception-caught + return False diff --git a/backend/openedx_ai_extensions/edxapp_wrapper/backends/student_module_test.py b/backend/openedx_ai_extensions/edxapp_wrapper/backends/student_module_test.py new file mode 100644 index 00000000..d0fa700f --- /dev/null +++ b/backend/openedx_ai_extensions/edxapp_wrapper/backends/student_module_test.py @@ -0,0 +1,5 @@ +""" Null backend for tests β€” always denies course-level access. """ + + +def permission_is_course_staff(user, course_id): + return False diff --git a/backend/openedx_ai_extensions/edxapp_wrapper/student_module.py b/backend/openedx_ai_extensions/edxapp_wrapper/student_module.py new file mode 100644 index 00000000..fee70db0 --- /dev/null +++ b/backend/openedx_ai_extensions/edxapp_wrapper/student_module.py @@ -0,0 +1,10 @@ +""" Backend abstraction for edx-platform student module. """ +from importlib import import_module + +from django.conf import settings + + +def permission_is_course_staff(user, course_id): + """ Return True if user may manage advanced settings for the given course. """ + backend = import_module(settings.STUDENT_MODULE_BACKEND) + return backend.permission_is_course_staff(user, course_id) diff --git a/backend/openedx_ai_extensions/settings/common.py b/backend/openedx_ai_extensions/settings/common.py index b9f7f7b9..b3d7c33b 100644 --- a/backend/openedx_ai_extensions/settings/common.py +++ b/backend/openedx_ai_extensions/settings/common.py @@ -54,6 +54,9 @@ def plugin_settings(settings): settings.CONTENT_LIBRARIES_MODULE_BACKEND = ( "openedx_ai_extensions.edxapp_wrapper.backends.content_libraries_module_t_v1" ) + settings.STUDENT_MODULE_BACKEND = ( + "openedx_ai_extensions.edxapp_wrapper.backends.student_module_t_v1" + ) # ------------------------- # Settings based config router diff --git a/backend/test_settings.py b/backend/test_settings.py index bcd7d4dd..50bc132e 100644 --- a/backend/test_settings.py +++ b/backend/test_settings.py @@ -149,3 +149,7 @@ def root(*args): # Only using the LMS context for simplicity # Third parameter is the settings_type which should match the keys in settings_config add_plugins(__name__, PLUGIN_CONTEXTS[0], "test") + +# Override the student module backend with a null implementation that never +# imports common.djangoapps, which is not available outside edx-platform. +STUDENT_MODULE_BACKEND = "openedx_ai_extensions.edxapp_wrapper.backends.student_module_test" diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 56bc30d3..0eec234f 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -738,12 +738,12 @@ def test_profiles_list_requires_authentication(api_client): # pylint: disable=r @pytest.mark.django_db -@pytest.mark.usefixtures("user") +@pytest.mark.usefixtures("staff_user") def test_profiles_list_happy_path(api_client, course_key): # pylint: disable=redefined-outer-name """ Two scopes with different slots pointing to two distinct profiles are both returned. """ - api_client.login(username="testuser", password="password123") + api_client.login(username="staffuser", password="password123") url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") profile_a = AIWorkflowProfile.objects.create( @@ -788,12 +788,12 @@ def test_profiles_list_happy_path(api_client, course_key): # pylint: disable=re @pytest.mark.django_db -@pytest.mark.usefixtures("user") +@pytest.mark.usefixtures("staff_user") def test_profiles_list_no_matches(api_client): # pylint: disable=redefined-outer-name """ Unknown course returns an empty list without errors. """ - api_client.login(username="testuser", password="password123") + api_client.login(username="staffuser", password="password123") url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") context = json.dumps({"courseId": "course-v1:Unknown+X+NoSuchCourse"}) @@ -806,7 +806,7 @@ def test_profiles_list_no_matches(api_client): # pylint: disable=redefined-oute @pytest.mark.django_db -@pytest.mark.usefixtures("user") +@pytest.mark.usefixtures("staff_user") @patch("openedx_ai_extensions.api.v1.workflows.views.AIWorkflowScope.list_profiles_for_context") def test_profiles_list_api_keys_are_redacted( # pylint: disable=redefined-outer-name mock_list, api_client, course_key @@ -827,7 +827,7 @@ def test_profiles_list_api_keys_are_redacted( # pylint: disable=redefined-outer mock_profile.matched_scopes = [] mock_list.return_value = [mock_profile] - api_client.login(username="testuser", password="password123") + api_client.login(username="staffuser", password="password123") url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") context = json.dumps({"courseId": str(course_key), "uiSlotSelectorId": "slot-redact"}) response = api_client.get(url, {"context": context}) @@ -842,12 +842,12 @@ def test_profiles_list_api_keys_are_redacted( # pylint: disable=redefined-outer @pytest.mark.django_db -@pytest.mark.usefixtures("user") +@pytest.mark.usefixtures("staff_user") def test_profiles_list_deduplication(api_client, course_key): # pylint: disable=redefined-outer-name """ Two scopes pointing to the same profile return only one profile entry. """ - api_client.login(username="testuser", password="password123") + api_client.login(username="staffuser", password="password123") url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") profile = AIWorkflowProfile.objects.create( @@ -876,12 +876,12 @@ def test_profiles_list_deduplication(api_client, course_key): # pylint: disable @pytest.mark.django_db -@pytest.mark.usefixtures("user") +@pytest.mark.usefixtures("staff_user") def test_profiles_list_filtered_by_ui_slot(api_client, course_key): # pylint: disable=redefined-outer-name """ When uiSlotSelectorId is provided, only profiles for that slot are returned. """ - api_client.login(username="testuser", password="password123") + api_client.login(username="staffuser", password="password123") url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") profile_a = AIWorkflowProfile.objects.create( @@ -909,14 +909,14 @@ def test_profiles_list_filtered_by_ui_slot(api_client, course_key): # pylint: d @pytest.mark.django_db -@pytest.mark.usefixtures("user") +@pytest.mark.usefixtures("staff_user") def test_profiles_list_no_slot_returns_all(api_client, course_key): # pylint: disable=redefined-outer-name """ When no uiSlotSelectorId is provided, all profiles for the course are returned regardless of their individual slot assignments. This is the intended call pattern for the Studio settings panel. """ - api_client.login(username="testuser", password="password123") + api_client.login(username="staffuser", password="password123") url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") profile_a = AIWorkflowProfile.objects.create( @@ -945,12 +945,12 @@ def test_profiles_list_no_slot_returns_all(api_client, course_key): # pylint: d @pytest.mark.django_db -@pytest.mark.usefixtures("user") +@pytest.mark.usefixtures("staff_user") def test_profiles_list_invalid_course_id(api_client): # pylint: disable=redefined-outer-name """ Malformed courseId returns HTTP 400 with an error key. """ - api_client.login(username="testuser", password="password123") + api_client.login(username="staffuser", password="password123") url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") context = json.dumps({"courseId": "not-a-valid-course-key"}) @@ -962,12 +962,12 @@ def test_profiles_list_invalid_course_id(api_client): # pylint: disable=redefin @pytest.mark.django_db -@pytest.mark.usefixtures("user") +@pytest.mark.usefixtures("staff_user") def test_profiles_list_no_context_param(api_client): # pylint: disable=redefined-outer-name """ Missing context param is treated as empty context β€” no crash, returns empty list. """ - api_client.login(username="testuser", password="password123") + api_client.login(username="staffuser", password="password123") url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") response = api_client.get(url) @@ -1068,7 +1068,7 @@ def test_profile_list_serializer_handles_none_config(): @pytest.mark.django_db @patch("openedx_ai_extensions.api.v1.workflows.views.AIWorkflowScope.list_profiles_for_context") -def test_profiles_list_view_returns_200_unit(mock_list, user): # pylint: disable=redefined-outer-name +def test_profiles_list_view_returns_200_unit(mock_list, staff_user): # pylint: disable=redefined-outer-name """ AIWorkflowProfilesListView returns 200 with correct shape (unit test). """ @@ -1082,7 +1082,7 @@ def test_profiles_list_view_returns_200_unit(mock_list, user): # pylint: disabl factory = APIRequestFactory() request = factory.get("/openedx-ai-extensions/v1/profiles/", {"context": "{}"}) - request.user = user + request.user = staff_user view = AIWorkflowProfilesListView.as_view() response = view(request) @@ -1097,7 +1097,7 @@ def test_profiles_list_view_returns_200_unit(mock_list, user): # pylint: disabl @pytest.mark.django_db @patch("openedx_ai_extensions.api.v1.workflows.views.AIWorkflowScope.list_profiles_for_context") -def test_profiles_list_view_invalid_context_json_unit(mock_list, user): # pylint: disable=redefined-outer-name +def test_profiles_list_view_invalid_context_json_unit(mock_list, staff_user): # pylint: disable=redefined-outer-name """ AIWorkflowProfilesListView returns 500 for invalid JSON context (unit test). """ @@ -1107,13 +1107,13 @@ def test_profiles_list_view_invalid_context_json_unit(mock_list, user): # pylin request = factory.get( "/openedx-ai-extensions/v1/profiles/", {"context": "invalid json{"} ) - request.user = user + request.user = staff_user view = AIWorkflowProfilesListView.as_view() response = view(request) response.render() - assert response.status_code == 500 + assert response.status_code == 400 # ============================================================================ diff --git a/backend/tests/test_edxapp_wrapper.py b/backend/tests/test_edxapp_wrapper.py index 2a11a1e2..34eaa729 100644 --- a/backend/tests/test_edxapp_wrapper.py +++ b/backend/tests/test_edxapp_wrapper.py @@ -23,7 +23,9 @@ # pylint: disable=wrong-import-position # These imports must come after mocking the openedx module from openedx_ai_extensions.edxapp_wrapper import content_libraries_module # noqa: E402 +from openedx_ai_extensions.edxapp_wrapper import student_module # noqa: E402 from openedx_ai_extensions.edxapp_wrapper.backends import content_libraries_module_t_v1 # noqa: E402 +from openedx_ai_extensions.edxapp_wrapper.backends import student_module_test # noqa: E402 # pylint: enable=wrong-import-position @@ -91,3 +93,41 @@ def test_backend_get_content_libraries(self): # The function simply returns the imported module, so as long as it returns something # and doesn't raise an exception, it's working correctly assert result is not None + + +class TestStudentModuleWrapper: + """ + Test the student_module wrapper and its test backend. + """ + + @override_settings( + STUDENT_MODULE_BACKEND="openedx_ai_extensions.edxapp_wrapper.backends.student_module_test" + ) + def test_permission_is_course_staff_delegates_to_backend(self): + """ + Wrapper calls the backend function and returns its result. + """ + with patch('openedx_ai_extensions.edxapp_wrapper.student_module.import_module') as mock_import: + mock_backend = MagicMock() + mock_backend.permission_is_course_staff.return_value = True + mock_import.return_value = mock_backend + + result = student_module.permission_is_course_staff( + MagicMock(), "course-v1:edX+Demo+2024" + ) + + mock_import.assert_called_once_with(settings.STUDENT_MODULE_BACKEND) + mock_backend.permission_is_course_staff.assert_called_once() + assert result is True + + @override_settings( + STUDENT_MODULE_BACKEND="openedx_ai_extensions.edxapp_wrapper.backends.student_module_test" + ) + def test_test_backend_always_denies(self): + """ + The test backend returns False for any user/course combination. + """ + result = student_module_test.permission_is_course_staff( + MagicMock(), "course-v1:edX+Demo+2024" + ) + assert result is False From e44009b0491f002fb0dd1bdaafb34ec3714f19a0 Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Thu, 7 May 2026 12:49:14 -0500 Subject: [PATCH 08/13] feat: sending the course context from the UI Co-Authored-By: Claude Sonnet 4.6 --- .../components/WorkflowsConfigTab.tsx | 21 +++++++++++++------ frontend/src/services/profilesService.ts | 18 ++++++++++++++-- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx index 114f0520..e2e3050c 100644 --- a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx +++ b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx @@ -12,7 +12,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Alert, Badge, Button, Form, OverlayTrigger, Spinner, Tooltip, } from '@openedx/paragon'; -import { AIWorkflowProfile, PromptTemplate } from '../../types'; +import { AIWorkflowProfile, PluginContext, PromptTemplate } from '../../types'; import { fetchProfilesList, fetchPromptTemplate, savePromptTemplate, } from '../../services/profilesService'; @@ -55,7 +55,15 @@ const RelativeDate = ({ dateStr }: { dateStr: string }) => { }; /** Structured editor for a PromptTemplate. */ -const PromptView = ({ data, identifier }: { data: PromptTemplate; identifier: string }) => { +const PromptView = ({ + data, + identifier, + contextData, +}: { + data: PromptTemplate; + identifier: string; + contextData: PluginContext; +}) => { const [body, setBody] = useState(data.body); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); @@ -67,7 +75,7 @@ const PromptView = ({ data, identifier }: { data: PromptTemplate; identifier: st setSaving(true); setSaveError(null); try { - await savePromptTemplate({ identifier, body }); + await savePromptTemplate({ identifier, body, contextData }); setSaved(true); setTimeout(() => setSaved(false), 3000); } catch { @@ -188,6 +196,7 @@ const ProfileListItem = ({ profile, isSelected, onSelect }: ProfileListItemProps const WorkflowsConfigTab = () => { const intl = useIntl(); + const contextData = prepareContextData({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [profiles, setProfiles] = useState([]); @@ -206,7 +215,7 @@ const WorkflowsConfigTab = () => { useEffect(() => { const controller = new AbortController(); - fetchProfilesList({ contextData: prepareContextData({}), signal: controller.signal }) + fetchProfilesList({ contextData, signal: controller.signal }) .then((data) => { setProfiles(data.profiles); if (data.profiles.length > 0) { setSelected(data.profiles[0]); } @@ -229,7 +238,7 @@ const WorkflowsConfigTab = () => { setPromptLoading(true); setPromptError(null); - fetchPromptTemplate({ identifier, signal: controller.signal }) + fetchPromptTemplate({ identifier, contextData, signal: controller.signal }) .then((data) => { setPromptData(data); setPromptLoading(false); }) .catch((err) => { if (err?.name === 'CanceledError' || err?.name === 'AbortError') { return; } @@ -282,7 +291,7 @@ const WorkflowsConfigTab = () => { return
{promptError}
; } if (promptData) { - return ; + return ; } return null; }; diff --git a/frontend/src/services/profilesService.ts b/frontend/src/services/profilesService.ts index 6c114976..18cdbd96 100644 --- a/frontend/src/services/profilesService.ts +++ b/frontend/src/services/profilesService.ts @@ -35,22 +35,36 @@ const getPromptUrl = (identifier: string): string => { export const fetchPromptTemplate = async ({ identifier, + contextData, signal = null, }: { identifier: string; + contextData?: PluginContext; signal?: AbortSignal | null; }): Promise => { - const response = await getAuthenticatedHttpClient().get(getPromptUrl(identifier), { signal }); + const params = new URLSearchParams(); + if (contextData) { + params.append('context', JSON.stringify(contextData)); + } + const url = `${getPromptUrl(identifier)}?${params.toString()}`; + const response = await getAuthenticatedHttpClient().get(url, { signal }); return camelCaseObject(response.data) as PromptTemplate; }; export const savePromptTemplate = async ({ identifier, body, + contextData, }: { identifier: string; body: string; + contextData?: PluginContext; }): Promise => { - const response = await getAuthenticatedHttpClient().patch(getPromptUrl(identifier), { body }); + const params = new URLSearchParams(); + if (contextData) { + params.append('context', JSON.stringify(contextData)); + } + const url = `${getPromptUrl(identifier)}?${params.toString()}`; + const response = await getAuthenticatedHttpClient().patch(url, { body }); return camelCaseObject(response.data) as PromptTemplate; }; From bc8c216958a05b22842ef024271bb012a8d66368 Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Thu, 7 May 2026 13:19:51 -0500 Subject: [PATCH 09/13] fix: addressing comments made by qa process Co-Authored-By: Claude Sonnet 4.6 --- .../openedx_ai_extensions/api/v1/workflows/views.py | 2 +- .../components/WorkflowsConfigTab.tsx | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/openedx_ai_extensions/api/v1/workflows/views.py b/backend/openedx_ai_extensions/api/v1/workflows/views.py index 2a393161..cf9a504b 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/views.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/views.py @@ -225,7 +225,7 @@ def _get_template(self, identifier): pass try: return PromptTemplate.objects.get(id=identifier) - except (PromptTemplate.DoesNotExist, Exception): # pylint: disable=broad-exception-caught + except (PromptTemplate.DoesNotExist, ValueError, ValidationError): return None def get(self, request, identifier): diff --git a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx index e2e3050c..9dc7d1df 100644 --- a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx +++ b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx @@ -36,6 +36,7 @@ const getPromptTemplate = (effectiveConfig: Record): string | null /** Relative time label with full date on hover. */ const RelativeDate = ({ dateStr }: { dateStr: string }) => { + const intl = useIntl(); const date = new Date(dateStr); const diffMs = Date.now() - date.getTime(); const diffSeconds = Math.round(diffMs / 1000); @@ -44,8 +45,8 @@ const RelativeDate = ({ dateStr }: { dateStr: string }) => { let unit: Intl.RelativeTimeFormatUnit; if (diffSeconds < 60) { value = -diffSeconds; unit = 'second'; } else if (diffSeconds < 3600) { value = -Math.round(diffSeconds / 60); unit = 'minute'; } else if (diffSeconds < 86400) { value = -Math.round(diffSeconds / 3600); unit = 'hour'; } else if (diffSeconds < 2592000) { value = -Math.round(diffSeconds / 86400); unit = 'day'; } else if (diffSeconds < 31536000) { value = -Math.round(diffSeconds / 2592000); unit = 'month'; } else { value = -Math.round(diffSeconds / 31536000); unit = 'year'; } - const relative = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }).format(value, unit); - const full = date.toLocaleString(); + const relative = new Intl.RelativeTimeFormat(intl.locale, { numeric: 'auto' }).format(value, unit); + const full = date.toLocaleString(intl.locale); return ( {full}}> @@ -65,17 +66,19 @@ const PromptView = ({ contextData: PluginContext; }) => { const [body, setBody] = useState(data.body); + const [baseline, setBaseline] = useState(data.body); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [saved, setSaved] = useState(false); - const isDirty = body !== data.body; + const isDirty = body !== baseline; const handleSave = async () => { setSaving(true); setSaveError(null); try { await savePromptTemplate({ identifier, body, contextData }); + setBaseline(body); setSaved(true); setTimeout(() => setSaved(false), 3000); } catch { @@ -291,7 +294,7 @@ const WorkflowsConfigTab = () => { return
{promptError}
; } if (promptData) { - return ; + return ; } return null; }; From 2ab2e0ac583a747e8696e579116be21e5b7bdc6b Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Thu, 7 May 2026 14:40:37 -0500 Subject: [PATCH 10/13] feat: moving all user facing strings to i18n messages --- .../components/WorkflowsConfigTab.tsx | 20 ++++--- .../src/ai-extensions-settings/messages.ts | 59 +++++++++++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx index 9dc7d1df..f27dfb66 100644 --- a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx +++ b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx @@ -65,6 +65,7 @@ const PromptView = ({ identifier: string; contextData: PluginContext; }) => { + const intl = useIntl(); const [body, setBody] = useState(data.body); const [baseline, setBaseline] = useState(data.body); const [saving, setSaving] = useState(false); @@ -82,7 +83,7 @@ const PromptView = ({ setSaved(true); setTimeout(() => setSaved(false), 3000); } catch { - setSaveError('Failed to save prompt. Please try again.'); + setSaveError(intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.save-error'])); } finally { setSaving(false); } @@ -103,17 +104,17 @@ const PromptView = ({
- Created + {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.created-label'])}
- Updated + {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.updated-label'])}
- Prompt body + {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.body-label'])} {saveError}}
- {saved && Saved!} + {saved && {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.saved'])}}
@@ -286,7 +289,7 @@ const WorkflowsConfigTab = () => { return (
- Loading prompt… + {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.loading'])}
); } @@ -343,10 +346,9 @@ const WorkflowsConfigTab = () => { size="sm" variant={view === v ? 'primary' : 'tertiary'} onClick={() => setView(v)} - className="text-capitalize" disabled={v === 'prompt' && !promptTemplate} > - {v} + {intl.formatMessage(messages[`openedx-ai-extensions.settings-modal.workflows.view.${v}`])} ))}
diff --git a/frontend/src/ai-extensions-settings/messages.ts b/frontend/src/ai-extensions-settings/messages.ts index 81386b76..51b3f19a 100644 --- a/frontend/src/ai-extensions-settings/messages.ts +++ b/frontend/src/ai-extensions-settings/messages.ts @@ -65,6 +65,65 @@ const messages = defineMessages({ defaultMessage: 'Configuration', description: 'Label for the effective configuration section of a profile card', }, + + // Profile detail view tab labels + 'openedx-ai-extensions.settings-modal.workflows.view.profile': { + id: 'openedx-ai-extensions.settings-modal.workflows.view.profile', + defaultMessage: 'Profile', + description: 'Tab label for the profile configuration view', + }, + 'openedx-ai-extensions.settings-modal.workflows.view.scopes': { + id: 'openedx-ai-extensions.settings-modal.workflows.view.scopes', + defaultMessage: 'Scopes', + description: 'Tab label for the profile scopes view', + }, + 'openedx-ai-extensions.settings-modal.workflows.view.prompt': { + id: 'openedx-ai-extensions.settings-modal.workflows.view.prompt', + defaultMessage: 'Prompt', + description: 'Tab label for the prompt template editor view', + }, + + // Prompt template editor + 'openedx-ai-extensions.settings-modal.workflows.prompt.loading': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.loading', + defaultMessage: 'Loading prompt…', + description: 'Loading message while fetching the prompt template', + }, + 'openedx-ai-extensions.settings-modal.workflows.prompt.created-label': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.created-label', + defaultMessage: 'Created', + description: 'Label for the prompt template creation date', + }, + 'openedx-ai-extensions.settings-modal.workflows.prompt.updated-label': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.updated-label', + defaultMessage: 'Updated', + description: 'Label for the prompt template last-updated date', + }, + 'openedx-ai-extensions.settings-modal.workflows.prompt.body-label': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.body-label', + defaultMessage: 'Prompt body', + description: 'Label for the prompt body textarea', + }, + 'openedx-ai-extensions.settings-modal.workflows.prompt.save': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.save', + defaultMessage: 'Save', + description: 'Label for the save button in the prompt editor', + }, + 'openedx-ai-extensions.settings-modal.workflows.prompt.saving': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.saving', + defaultMessage: 'Saving…', + description: 'Label shown on the save button while the save request is in flight', + }, + 'openedx-ai-extensions.settings-modal.workflows.prompt.saved': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.saved', + defaultMessage: 'Saved!', + description: 'Confirmation message shown briefly after a successful save', + }, + 'openedx-ai-extensions.settings-modal.workflows.prompt.save-error': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.save-error', + defaultMessage: 'Failed to save prompt. Please try again.', + description: 'Error message shown when the save request fails', + }, }); export default messages; From 25e97388056a929c35be7a0229c1c9343c13cb9d Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Thu, 7 May 2026 17:59:01 -0500 Subject: [PATCH 11/13] feat: making the number of profiles affected by a template change available Co-Authored-By: Claude Sonnet 4.6 --- .../api/v1/workflows/serializers.py | 62 ++++++++++++++-- .../components/WorkflowsConfigTab.tsx | 70 ++++++++++++++++++- .../src/ai-extensions-settings/messages.ts | 34 +++++++++ frontend/src/types.ts | 2 + 4 files changed, 160 insertions(+), 8 deletions(-) diff --git a/backend/openedx_ai_extensions/api/v1/workflows/serializers.py b/backend/openedx_ai_extensions/api/v1/workflows/serializers.py index be081691..20221616 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/serializers.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/serializers.py @@ -4,9 +4,12 @@ import copy +from django.db.models import Q from rest_framework import serializers from openedx_ai_extensions.models import PromptTemplate +from openedx_ai_extensions.workflows.models import AIWorkflowProfile + # Keys whose values must never be exposed to the frontend. _SENSITIVE_KEYS = frozenset({ @@ -62,7 +65,8 @@ class PromptTemplateSerializer(serializers.Serializer): """ Serializer for a PromptTemplate instance. - Exposes all public fields of the template. + Exposes all public fields of the template plus a ``usage`` object that counts + how many AIWorkflowProfile configs reference this template. """ id = serializers.UUIDField(read_only=True) @@ -70,6 +74,50 @@ class PromptTemplateSerializer(serializers.Serializer): body = serializers.CharField(read_only=True) created_at = serializers.DateTimeField(read_only=True) updated_at = serializers.DateTimeField(read_only=True) + usage = serializers.SerializerMethodField() + + def get_usage(self, obj): + """ + Count how many AIWorkflowProfile configs reference this template. + + Phase 1 β€” DB text search: filter profiles whose ``content_patch`` contains + the template slug or UUID string (fast LIKE/ILIKE, no disk reads). + Phase 2 β€” effective-config check: compute the merged config only for those + candidates and confirm the reference is in ``processor_config``. + + Returns ``{"profile_count": None}`` if the count cannot be determined, so + callers can distinguish "zero uses" from "unknown" without a 500. + """ + try: + slug = obj.slug + uuid_str = str(obj.id) + + candidates = AIWorkflowProfile.objects.filter( + Q(content_patch__icontains=slug) | Q(content_patch__icontains=uuid_str), + content_patch__icontains="prompt_template", + ) + + count = 0 + for profile in candidates: + try: + config = profile.config or {} + except Exception: # pylint: disable=broad-exception-caught + continue + processor_config = config.get("processor_config") or {} + if not isinstance(processor_config, dict): + continue + for processor in processor_config.values(): + if not isinstance(processor, dict): + continue + template_ref = processor.get("prompt_template") + if template_ref and ( + str(template_ref) == uuid_str or str(template_ref) == slug + ): + count += 1 + break + return {"profile_count": count} + except Exception: # pylint: disable=broad-exception-caught + return {"profile_count": None} def create(self, validated_data): """Read-only serializer β€” creation not supported.""" @@ -158,10 +206,9 @@ class AIWorkflowProfileListSerializer(serializers.Serializer): Serializer for a single AIWorkflowProfile in the profiles list endpoint. Exposes the profile's identity fields, its complete effective configuration - with sensitive values redacted, and the list of scopes that link to it in - the current request context. Designed to be extended in future iterations - to expose globally-configured provider information alongside profile-level - settings. + with sensitive values redacted, the list of scopes that link to it in the + current request context, and a ``usage`` object with the total scope count + across all courses and contexts. """ id = serializers.UUIDField(read_only=True) @@ -169,6 +216,7 @@ class AIWorkflowProfileListSerializer(serializers.Serializer): description = serializers.CharField(allow_null=True, read_only=True) effective_config = serializers.SerializerMethodField() scopes = serializers.SerializerMethodField() + usage = serializers.SerializerMethodField() def get_effective_config(self, obj): """Return effective config with sensitive values redacted.""" @@ -180,6 +228,10 @@ def get_scopes(self, obj): matched_scopes = getattr(obj, "matched_scopes", []) return AIWorkflowScopeSerializer(matched_scopes, many=True).data + def get_usage(self, obj): + """Return total number of scopes pointing to this profile across all contexts.""" + return {"scope_count": obj.aiworkflowscope_set.count()} + def create(self, validated_data): """Read-only serializer β€” creation not supported.""" raise NotImplementedError("AIWorkflowProfileListSerializer is read-only") diff --git a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx index f27dfb66..12267b64 100644 --- a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx +++ b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx @@ -10,7 +10,7 @@ import { useEffect, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { - Alert, Badge, Button, Form, OverlayTrigger, Spinner, Tooltip, + ActionRow, Alert, Badge, Button, Form, ModalDialog, OverlayTrigger, Spinner, Tooltip, } from '@openedx/paragon'; import { AIWorkflowProfile, PluginContext, PromptTemplate } from '../../types'; import { @@ -71,10 +71,12 @@ const PromptView = ({ const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [saved, setSaved] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); const isDirty = body !== baseline; + const isShared = (data.usage?.profileCount ?? 0) > 1; - const handleSave = async () => { + const doSave = async () => { setSaving(true); setSaveError(null); try { @@ -89,6 +91,14 @@ const PromptView = ({ } }; + const handleSave = () => { + if (isShared) { + setShowConfirm(true); + } else { + doSave(); + } + }; + return (
@@ -127,6 +137,14 @@ const PromptView = ({ {saveError && {saveError}}
+ {data.usage !== undefined && ( + 1 ? 'warning' : 'light'}> + {intl.formatMessage( + messages['openedx-ai-extensions.settings-modal.workflows.prompt.usage-profiles'], + { count: data.usage.profileCount }, + )} + + )} {saved && {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.saved'])}}
+ + setShowConfirm(false)} + hasCloseButton + > + + + {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.confirm-save-title'])} + + + +

+ {intl.formatMessage( + messages['openedx-ai-extensions.settings-modal.workflows.prompt.confirm-save-body'], + { count: data.usage?.profileCount ?? 0 }, + )} +

+
+ + + + + + + +
); }; @@ -357,7 +407,21 @@ const WorkflowsConfigTab = () => { flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', }} > - {view === 'profile' && } + {view === 'profile' && ( + <> + + {selected.usage !== undefined && ( +
+ + {intl.formatMessage( + messages['openedx-ai-extensions.settings-modal.workflows.profile.usage-scopes'], + { count: selected.usage.scopeCount }, + )} + +
+ )} + + )} {view === 'scopes' && } {view === 'prompt' && renderPromptContent()}
diff --git a/frontend/src/ai-extensions-settings/messages.ts b/frontend/src/ai-extensions-settings/messages.ts index 51b3f19a..892d6534 100644 --- a/frontend/src/ai-extensions-settings/messages.ts +++ b/frontend/src/ai-extensions-settings/messages.ts @@ -124,6 +124,40 @@ const messages = defineMessages({ defaultMessage: 'Failed to save prompt. Please try again.', description: 'Error message shown when the save request fails', }, + + // Confirmation modal for saving a shared prompt template + 'openedx-ai-extensions.settings-modal.workflows.prompt.confirm-save-title': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.confirm-save-title', + defaultMessage: 'Save shared prompt?', + description: 'Title of the confirmation modal shown before saving a prompt used by multiple profiles', + }, + 'openedx-ai-extensions.settings-modal.workflows.prompt.confirm-save-body': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.confirm-save-body', + defaultMessage: 'This prompt is used by {count, plural, one {1 profile} other {{count} profiles}}. Saving will affect all of them.', + description: 'Body of the confirmation modal shown before saving a shared prompt template', + }, + 'openedx-ai-extensions.settings-modal.workflows.prompt.confirm-save-cancel': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.confirm-save-cancel', + defaultMessage: 'Cancel', + description: 'Cancel button label in the shared-prompt save confirmation modal', + }, + 'openedx-ai-extensions.settings-modal.workflows.prompt.confirm-save-confirm': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.confirm-save-confirm', + defaultMessage: 'Save anyway', + description: 'Confirm button label in the shared-prompt save confirmation modal', + }, + + // Usage counts + 'openedx-ai-extensions.settings-modal.workflows.prompt.usage-profiles': { + id: 'openedx-ai-extensions.settings-modal.workflows.prompt.usage-profiles', + defaultMessage: 'Used by {count, plural, one {1 profile} other {{count} profiles}}', + description: 'Badge showing how many AI workflow profiles reference this prompt template', + }, + 'openedx-ai-extensions.settings-modal.workflows.profile.usage-scopes': { + id: 'openedx-ai-extensions.settings-modal.workflows.profile.usage-scopes', + defaultMessage: '{count, plural, one {1 scope} other {{count} scopes}}', + description: 'Badge showing how many scopes link to this AI workflow profile', + }, }); export default messages; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4dd0572e..72efc4f1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -49,6 +49,7 @@ export interface PromptTemplate { body: string; createdAt: string; updatedAt: string; + usage?: { profileCount: number }; } export interface AIWorkflowScope { @@ -67,6 +68,7 @@ export interface AIWorkflowProfile { description: string | null; effectiveConfig: Record; scopes: AIWorkflowScope[]; + usage?: { scopeCount: number }; } export interface ProfilesListResponse { From 6e1ae6e0297fd49c8f97c7b98f8719cd85751a4d Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Thu, 7 May 2026 18:32:47 -0500 Subject: [PATCH 12/13] fix: all the qa addressed --- backend/.coveragerc | 1 + .../api/v1/workflows/serializers.py | 1 - .../api/v1/workflows/views.py | 5 +- .../backends/student_module_test.py | 2 +- backend/tests/test_api.py | 86 +++++++++++++++++++ .../components/WorkflowsConfigTab.tsx | 12 +-- frontend/src/constants.ts | 1 + frontend/src/services/profilesService.ts | 4 +- 8 files changed, 99 insertions(+), 13 deletions(-) diff --git a/backend/.coveragerc b/backend/.coveragerc index ad8532e5..41f76b4b 100644 --- a/backend/.coveragerc +++ b/backend/.coveragerc @@ -8,3 +8,4 @@ omit = *admin.py */static/* */templates/* + */edxapp_wrapper/backends/* diff --git a/backend/openedx_ai_extensions/api/v1/workflows/serializers.py b/backend/openedx_ai_extensions/api/v1/workflows/serializers.py index 20221616..88682895 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/serializers.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/serializers.py @@ -10,7 +10,6 @@ from openedx_ai_extensions.models import PromptTemplate from openedx_ai_extensions.workflows.models import AIWorkflowProfile - # Keys whose values must never be exposed to the frontend. _SENSITIVE_KEYS = frozenset({ "api_key", diff --git a/backend/openedx_ai_extensions/api/v1/workflows/views.py b/backend/openedx_ai_extensions/api/v1/workflows/views.py index cf9a504b..3bfe99d2 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/views.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/views.py @@ -17,10 +17,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from openedx_ai_extensions.api.v1.workflows.permissions import ( - CourseStaffPermission, - get_context_from_request, -) +from openedx_ai_extensions.api.v1.workflows.permissions import CourseStaffPermission, get_context_from_request from openedx_ai_extensions.decorators import handle_ai_errors from openedx_ai_extensions.models import PromptTemplate from openedx_ai_extensions.utils import is_generator diff --git a/backend/openedx_ai_extensions/edxapp_wrapper/backends/student_module_test.py b/backend/openedx_ai_extensions/edxapp_wrapper/backends/student_module_test.py index d0fa700f..e27a76d3 100644 --- a/backend/openedx_ai_extensions/edxapp_wrapper/backends/student_module_test.py +++ b/backend/openedx_ai_extensions/edxapp_wrapper/backends/student_module_test.py @@ -1,5 +1,5 @@ """ Null backend for tests β€” always denies course-level access. """ -def permission_is_course_staff(user, course_id): +def permission_is_course_staff(user, course_id): # pylint: disable=unused-argument return False diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0eec234f..01a80d44 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -8,6 +8,7 @@ import pytest from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError as DjangoValidationError from django.urls import reverse from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import BlockUsageLocator @@ -17,9 +18,14 @@ sys.modules["submissions"] = MagicMock() sys.modules["submissions.api"] = MagicMock() +from openedx_ai_extensions.api.v1.workflows.permissions import ( # noqa: E402 pylint: disable=wrong-import-position + CourseStaffPermission, + get_context_from_request, +) from openedx_ai_extensions.api.v1.workflows.serializers import ( # noqa: E402 pylint: disable=wrong-import-position AIWorkflowProfileListSerializer, AIWorkflowProfileSerializer, + AIWorkflowScopeSerializer, PromptTemplateSerializer, redact_sensitive_config, ) @@ -825,6 +831,7 @@ def test_profiles_list_api_keys_are_redacted( # pylint: disable=redefined-outer "processor_config": {"LLMProcessor": {"options": {"api_key": "sk-secret-123"}}} } mock_profile.matched_scopes = [] + mock_profile.aiworkflowscope_set.count.return_value = 0 mock_list.return_value = [mock_profile] api_client.login(username="staffuser", password="password123") @@ -1078,6 +1085,7 @@ def test_profiles_list_view_returns_200_unit(mock_list, staff_user): # pylint: mock_profile.description = "Mock" mock_profile.config = {} mock_profile.matched_scopes = [] + mock_profile.aiworkflowscope_set.count.return_value = 0 mock_list.return_value = [mock_profile] factory = APIRequestFactory() @@ -1321,3 +1329,81 @@ def test_prompt_template_serializer_read_only(prompt_template): # pylint: disab serializer.create({}) with pytest.raises(NotImplementedError): serializer.update(prompt_template, {}) + + +# ============================================================================ +# AIWorkflowScopeSerializer β€” read-only guard +# ============================================================================ + +def test_scope_serializer_create_not_implemented(): + """AIWorkflowScopeSerializer.create raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + AIWorkflowScopeSerializer().create({}) + + +def test_scope_serializer_update_not_implemented(): + """AIWorkflowScopeSerializer.update raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + AIWorkflowScopeSerializer().update(None, {}) + + +# ============================================================================ +# get_context_from_request β€” uncovered branches +# ============================================================================ + +def test_get_context_from_request_invalid_location_id(): + """Invalid locationId format raises ValidationError.""" + mock_request = Mock() + mock_request.GET = {"context": json.dumps({"locationId": "not-a-valid-block-key"})} + with pytest.raises(DjangoValidationError): + get_context_from_request(mock_request) + + +def test_get_context_from_request_uses_query_params_when_no_get(): + """Falls back to request.query_params when request has no .GET attribute.""" + mock_request = Mock(spec=["query_params"]) + mock_request.query_params = {"context": "{}"} + result = get_context_from_request(mock_request) + assert not result + + +# ============================================================================ +# CourseStaffPermission β€” non-staff paths +# ============================================================================ + +@pytest.mark.django_db +def test_course_staff_permission_denied_invalid_context(user): # pylint: disable=redefined-outer-name + """Non-staff user with unparseable course_id is denied without raising.""" + permission = CourseStaffPermission() + mock_request = Mock() + mock_request.user = user + mock_request.GET = {"context": json.dumps({"courseId": "not-a-valid-key"})} + assert permission.has_permission(mock_request, None) is False + + +@pytest.mark.django_db +def test_course_staff_permission_delegates_to_backend(user, course_key): # pylint: disable=redefined-outer-name + """Non-staff user with valid course_id reaches permission_is_course_staff (test backend β†’ False).""" + permission = CourseStaffPermission() + mock_request = Mock() + mock_request.user = user + mock_request.GET = {"context": json.dumps({"courseId": str(course_key)})} + assert permission.has_permission(mock_request, None) is False + + +# ============================================================================ +# AIWorkflowProfilesListView β€” unexpected-error path (500) +# ============================================================================ + +@pytest.mark.django_db +@pytest.mark.usefixtures("staff_user") +@patch("openedx_ai_extensions.api.v1.workflows.views.AIWorkflowScope.list_profiles_for_context") +def test_profiles_list_view_unexpected_error(mock_list, api_client, course_key): # pylint: disable=redefined-outer-name + """Unhandled exception inside the view returns 500 with status='error'.""" + mock_list.side_effect = RuntimeError("unexpected boom") + api_client.login(username="staffuser", password="password123") + url = reverse("openedx_ai_extensions:api:v1:aiext_profiles_list") + context = json.dumps({"courseId": str(course_key)}) + response = api_client.get(url, {"context": context}) + assert response.status_code == 500 + assert response.json()["status"] == "error" diff --git a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx index 12267b64..fb42c461 100644 --- a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx +++ b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx @@ -7,7 +7,7 @@ * to avoid duplicate @codemirror/state instances). */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Alert, Badge, Button, Form, ModalDialog, OverlayTrigger, Spinner, Tooltip, @@ -252,7 +252,7 @@ const ProfileListItem = ({ profile, isSelected, onSelect }: ProfileListItemProps const WorkflowsConfigTab = () => { const intl = useIntl(); - const contextData = prepareContextData({}); + const contextData = useMemo(() => prepareContextData({}), []); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [profiles, setProfiles] = useState([]); @@ -283,7 +283,7 @@ const WorkflowsConfigTab = () => { setLoading(false); }); return () => controller.abort(); - }, [intl]); + }, [intl, contextData]); useEffect(() => { if (view !== 'prompt' || !selected) { return undefined; } @@ -303,7 +303,7 @@ const WorkflowsConfigTab = () => { }); return () => controller.abort(); - }, [view, selected, intl]); + }, [view, selected, intl, contextData]); if (loading) { return ( @@ -347,7 +347,9 @@ const WorkflowsConfigTab = () => { return
{promptError}
; } if (promptData) { - return ; + return ( + + ); } return null; }; diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 4ba10e6e..a17ff564 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -12,6 +12,7 @@ export const ENDPOINT_TYPES = { WORKFLOWS: 'workflows', PROFILE: 'profile', LIST_PROFILES: 'profiles', + PROMPTS: 'prompts', } as const; export type EndpointType = typeof ENDPOINT_TYPES[keyof typeof ENDPOINT_TYPES]; diff --git a/frontend/src/services/profilesService.ts b/frontend/src/services/profilesService.ts index 18cdbd96..19be100d 100644 --- a/frontend/src/services/profilesService.ts +++ b/frontend/src/services/profilesService.ts @@ -29,8 +29,8 @@ export const fetchProfilesList = async ({ }; const getPromptUrl = (identifier: string): string => { - const base = getDefaultEndpoint('prompts' as any).replace(/\/$/, ''); - return `${base}/${identifier}/`; + const base = getDefaultEndpoint(ENDPOINT_TYPES.PROMPTS); + return `${base}${identifier}/`; }; export const fetchPromptTemplate = async ({ From c754525cbce9d562c8c9e2c15d3d3e8e0e2f94ac Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Fri, 8 May 2026 18:08:07 -0500 Subject: [PATCH 13/13] fix: addressing comments by @henrrypg --- backend/openedx_ai_extensions/api/v1/workflows/permissions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/openedx_ai_extensions/api/v1/workflows/permissions.py b/backend/openedx_ai_extensions/api/v1/workflows/permissions.py index cf84088e..0f883997 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/permissions.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/permissions.py @@ -42,7 +42,6 @@ def get_context_from_request(request): raise ValidationError("Invalid JSON format in 'context' parameter.") from e validated_context = {} - # Validate and convert courseId to course_id course_id_raw = context.get("courseId") or context.get("course_id") if course_id_raw: try: @@ -51,7 +50,6 @@ def get_context_from_request(request): except InvalidKeyError as e: raise ValidationError(f"Invalid course_id format: {course_id_raw}") from e - # Validate and convert locationId to location_id location_id_raw = context.get("locationId") or context.get("location_id") if location_id_raw: try: @@ -60,7 +58,6 @@ def get_context_from_request(request): except InvalidKeyError as e: raise ValidationError(f"Invalid location_id format: {location_id_raw}") from e - # Pass ui_slot_selector_id as-is (plain string, no special validation needed) ui_slot_selector_id_raw = context.get("uiSlotSelectorId") or context.get("ui_slot_selector_id") if ui_slot_selector_id_raw: validated_context["ui_slot_selector_id"] = str(ui_slot_selector_id_raw)