Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
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"),
]
119 changes: 119 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,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
Comment thread
felipemontoya marked this conversation as resolved.
Outdated
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
Comment thread
felipemontoya marked this conversation as resolved.
Outdated
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)
Comment thread
felipemontoya marked this conversation as resolved.
Outdated
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)
Comment thread
felipemontoya marked this conversation as resolved.
Outdated

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(
Comment thread
felipemontoya marked this conversation as resolved.
Outdated
request.user.username,
_COURSE_ADVANCED_SETTINGS_ACTION,
course_id,
)
except Exception: # pylint: disable=broad-exception-caught
return False
161 changes: 161 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,109 @@
Serializers for AI Workflows API
"""

import copy

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",
"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.
"""

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):
"""
Expand All @@ -26,3 +127,63 @@ 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, 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")

def update(self, instance, validated_data):
"""Read-only serializer — update not supported."""
raise NotImplementedError("AIWorkflowProfileListSerializer is read-only")
Loading
Loading