Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/.coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ omit =
*admin.py
*/static/*
*/templates/*
*/edxapp_wrapper/backends/*
9 changes: 8 additions & 1 deletion backend/openedx_ai_extensions/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<str:identifier>/", PromptTemplateDetailView.as_view(), name="aiext_prompt_detail"),
]
98 changes: 98 additions & 0 deletions backend/openedx_ai_extensions/api/v1/workflows/permissions.py
Original file line number Diff line number Diff line change
@@ -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)
212 changes: 212 additions & 0 deletions backend/openedx_ai_extensions/api/v1/workflows/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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")
Loading
Loading