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/urls.py b/backend/openedx_ai_extensions/api/v1/urls.py index bdca806f..cdd76ea8 100644 --- a/backend/openedx_ai_extensions/api/v1/urls.py +++ b/backend/openedx_ai_extensions/api/v1/urls.py @@ -4,11 +4,18 @@ from django.urls import path -from .workflows.views import AIGenericWorkflowView, AIWorkflowProfileView +from .workflows.views import ( + AIGenericWorkflowView, + AIWorkflowProfilesListView, + AIWorkflowProfileView, + PromptTemplateDetailView, +) 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"), + path("prompts//", PromptTemplateDetailView.as_view(), name="aiext_prompt_detail"), ] 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..0f883997 --- /dev/null +++ b/backend/openedx_ai_extensions/api/v1/workflows/permissions.py @@ -0,0 +1,98 @@ +""" +DRF permission classes and shared request utilities for AI Workflows API. +""" + +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 + +from openedx_ai_extensions.edxapp_wrapper.student_module import permission_is_course_staff + +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 = {} + + 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 + + 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 + + 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 CourseStaffPermission(BasePermission): + """ + Restricts access to users who are authorised to manage advanced settings + for a course. + + * 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): + user = request.user + + if not user or not user.is_authenticated: + return False + + if user.is_staff or user.is_superuser: + return True + + try: + context = get_context_from_request(request) + 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 + + return permission_is_course_staff(user, course_id) diff --git a/backend/openedx_ai_extensions/api/v1/workflows/serializers.py b/backend/openedx_ai_extensions/api/v1/workflows/serializers.py index 21bd6708..88682895 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/serializers.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/serializers.py @@ -2,8 +2,156 @@ Serializers for AI Workflows API """ +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({ + "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 PromptTemplateSerializer(serializers.Serializer): + """ + Serializer for a PromptTemplate instance. + + 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) + 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) + 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.""" + 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): """ @@ -26,3 +174,67 @@ def create(self, validated_data): def update(self, instance, validated_data): """Read-only serializer — update not supported.""" 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, its complete effective configuration + 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) + slug = serializers.SlugField(read_only=True) + 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.""" + 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 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") + + 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..3bfe99d2 100644 --- a/backend/openedx_ai_extensions/api/v1/workflows/views.py +++ b/backend/openedx_ai_extensions/api/v1/workflows/views.py @@ -12,75 +12,27 @@ 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 IsAuthenticated 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.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 AIWorkflowProfileSerializer +from .serializers import ( + AIWorkflowProfileListSerializer, + AIWorkflowProfileSerializer, + PromptTemplateSerializer, + PromptTemplateUpdateSerializer, +) 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): @@ -166,3 +118,157 @@ 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. + """ + + permission_classes = [CourseStaffPermission] + + 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, + ) + + +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//`` + """ + + permission_classes = [CourseStaffPermission] + + 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, ValueError, ValidationError): + 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/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..e27a76d3 --- /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): # pylint: disable=unused-argument + 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/openedx_ai_extensions/workflows/models.py b/backend/openedx_ai_extensions/workflows/models.py index 06a867ed..64ea81af 100644 --- a/backend/openedx_ai_extensions/workflows/models.py +++ b/backend/openedx_ai_extensions/workflows/models.py @@ -301,6 +301,85 @@ 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. + 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} + 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") + + # profile_id → (profile, [matching scopes]) preserving insertion order + seen: dict = {} + + 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: + 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 + 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/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 6a0d8887..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,12 +18,22 @@ 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, ) 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, @@ -706,3 +717,693 @@ 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("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="staffuser", 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 + 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 +@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="staffuser", 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("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 +): + """ + 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_profile.matched_scopes = [] + mock_profile.aiworkflowscope_set.count.return_value = 0 + mock_list.return_value = [mock_profile] + + 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}) + + 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("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="staffuser", 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" + # 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 +@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="staffuser", 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("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="staffuser", 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("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="staffuser", 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("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="staffuser", 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, staff_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_profile.matched_scopes = [] + mock_profile.aiworkflowscope_set.count.return_value = 0 + mock_list.return_value = [mock_profile] + + factory = APIRequestFactory() + request = factory.get("/openedx-ai-extensions/v1/profiles/", {"context": "{}"}) + request.user = staff_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, staff_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 = staff_user + + view = AIWorkflowProfilesListView.as_view() + response = view(request) + response.render() + + assert response.status_code == 400 + + +# ============================================================================ +# 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, {}) + + +# ============================================================================ +# 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/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 diff --git a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx index a4f76285..fb42c461 100644 --- a/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx +++ b/frontend/src/ai-extensions-settings/components/WorkflowsConfigTab.tsx @@ -1,20 +1,436 @@ /** * WorkflowsConfigTab Component - * Placeholder tab for workflows configuration (to be developed) + * 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 + * to avoid duplicate @codemirror/state instances). */ +import { useEffect, useMemo, useState } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Alert } from '@openedx/paragon'; +import { + ActionRow, Alert, Badge, Button, Form, ModalDialog, OverlayTrigger, Spinner, Tooltip, +} from '@openedx/paragon'; +import { AIWorkflowProfile, PluginContext, 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'; + +/** 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 intl = useIntl(); + 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(intl.locale, { numeric: 'auto' }).format(value, unit); + const full = date.toLocaleString(intl.locale); + + return ( + {full}}> + {relative} + + ); +}; + +/** Structured editor for a PromptTemplate. */ +const PromptView = ({ + data, + identifier, + contextData, +}: { + data: PromptTemplate; + identifier: string; + contextData: PluginContext; +}) => { + const intl = useIntl(); + 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 [showConfirm, setShowConfirm] = useState(false); + + const isDirty = body !== baseline; + const isShared = (data.usage?.profileCount ?? 0) > 1; + + const doSave = async () => { + setSaving(true); + setSaveError(null); + try { + await savePromptTemplate({ identifier, body, contextData }); + setBaseline(body); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch { + setSaveError(intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.save-error'])); + } finally { + setSaving(false); + } + }; + + const handleSave = () => { + if (isShared) { + setShowConfirm(true); + } else { + doSave(); + } + }; + + return ( +
+
+ + ID + + + + Slug + + +
+ +
+
+ {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.created-label'])} + +
+
+ {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.updated-label'])} + +
+
+ + + {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.body-label'])} + ) => setBody(e.target.value)} + style={{ resize: 'vertical', lineHeight: 1.6 }} + /> + + + {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 }, + )} +

+
+ + + + + + + +
+
+ ); +}; + +/** Read-only JSON viewer. */ +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 contextData = useMemo(() => prepareContextData({}), []); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + 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(); + 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(); + }, [intl, contextData]); + + 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, contextData, 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, contextData]); + + 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) : ''; + const scopesJson = selected ? JSON.stringify(selected.scopes, null, 2) : ''; + const promptTemplate = selected ? getPromptTemplate(selected.effectiveConfig) : null; + + const renderPromptContent = () => { + if (promptLoading) { + return ( +
+ + {intl.formatMessage(messages['openedx-ai-extensions.settings-modal.workflows.prompt.loading'])} +
+ ); + } + if (promptError) { + return
{promptError}
; + } + if (promptData) { + return ( + + ); + } + return null; + }; 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} + {(['profile', 'scopes', 'prompt'] as ProfileView[]).map((v) => ( + + ))} +
+ +
+ {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 13be5274..892d6534 100644 --- a/frontend/src/ai-extensions-settings/messages.ts +++ b/frontend/src/ai-extensions-settings/messages.ts @@ -38,6 +38,126 @@ 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 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': { + id: 'openedx-ai-extensions.settings-modal.workflows.profiles.config-label', + 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', + }, + + // 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/constants.ts b/frontend/src/constants.ts index dbbe374e..a17ff564 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -11,6 +11,8 @@ export const DEFAULT_CHUNK_RATE_LIMIT_MS = 50; 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/index.ts b/frontend/src/services/index.ts index de4379eb..8f9d44da 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -6,6 +6,12 @@ export { fetchConfiguration, } from './configService'; +export { + fetchProfilesList, + fetchPromptTemplate, + savePromptTemplate, +} 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..19be100d --- /dev/null +++ b/frontend/src/services/profilesService.ts @@ -0,0 +1,70 @@ +/** + * Profiles Service + * 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, PromptTemplate } from '../types'; +import { ENDPOINT_TYPES } from '../constants'; +import { getDefaultEndpoint } from './utils'; + +interface FetchProfilesListParams { + contextData: PluginContext; + signal?: AbortSignal | null; +} + +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 response = await getAuthenticatedHttpClient().get(url, { signal }); + return camelCaseObject(response.data) as ProfilesListResponse; +}; + +const getPromptUrl = (identifier: string): string => { + const base = getDefaultEndpoint(ENDPOINT_TYPES.PROMPTS); + return `${base}${identifier}/`; +}; + +export const fetchPromptTemplate = async ({ + identifier, + contextData, + signal = null, +}: { + identifier: string; + contextData?: PluginContext; + signal?: AbortSignal | null; +}): Promise => { + 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 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; +}; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e0df95b0..72efc4f1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -42,3 +42,37 @@ export interface AIChatMessage extends Message { export interface AIModelResponse extends Message { role: 'user' | 'assistant'; } + +export interface PromptTemplate { + id: string; + slug: string; + body: string; + createdAt: string; + updatedAt: string; + usage?: { profileCount: number }; +} + +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[]; + usage?: { scopeCount: number }; +} + +export interface ProfilesListResponse { + profiles: AIWorkflowProfile[]; + count: number; + timestamp: string; +}