diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 248e37a04..cb666597a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,6 +18,10 @@ Unreleased * nothing unreleased +[8.0.15] - 2026-05-15 +--------------------- +* feat: add GradeEventContextEnricher pipeline step for grade analytics (ENT-11563) + [8.0.14] - 2026-05-14 --------------------- * feat: Add basic logging for all enterprise filter pipeline steps (ENT-11830) diff --git a/enterprise/__init__.py b/enterprise/__init__.py index c8e16ea05..a123fbe36 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "8.0.14" +__version__ = "8.0.15" diff --git a/enterprise/filters/__init__.py b/enterprise/filters/__init__.py index e69de29bb..87ba36052 100644 --- a/enterprise/filters/__init__.py +++ b/enterprise/filters/__init__.py @@ -0,0 +1,3 @@ +""" +Filter pipeline step implementations for edx-enterprise openedx-filters integrations. +""" diff --git a/enterprise/filters/grades.py b/enterprise/filters/grades.py new file mode 100644 index 000000000..c1f0eb77d --- /dev/null +++ b/enterprise/filters/grades.py @@ -0,0 +1,47 @@ +""" +Pipeline step for enriching grade analytics event context. +""" +import copy +from typing import Any + +from openedx_filters.filters import PipelineStep + +from enterprise.models import EnterpriseCourseEnrollment + + +class GradeEventContextEnricher(PipelineStep): + """ + Enriches a grade analytics event context dict with the learner's enterprise UUID. + + This step is intended to be registered as a pipeline step for the + ``org.openedx.learning.grade.context.requested.v1`` filter. + + If the user is enrolled in the given course through an enterprise, the enterprise + UUID is added to the context under the key ``"enterprise_uuid"``. If the user has + no enterprise course enrollment, the context is returned unchanged. + """ + + def run_filter(self, context: dict, user_id: int, course_id: str) -> dict[str, Any]: # pylint: disable=arguments-differ + """ + Add enterprise UUID to the event context if the user has an enterprise enrollment. + + Arguments: + context (dict): the event tracking context dict. + user_id (int): the ID of the user whose grade event is being emitted. + course_id (str): the course key for the grade event. + + Returns: + dict: updated pipeline data with the enriched ``context`` dict:: + + { + "context": , + "user_id": , + "course_id": , + } + """ + uuids = EnterpriseCourseEnrollment.get_enterprise_uuids_with_user_and_course(user_id, course_id) + if uuids: + context = copy.deepcopy(context) # create a copy to avoid altering the passed-in value. + # Warning: Selecting the first element is not likely deterministic! + context["enterprise_uuid"] = str(uuids[0]) + return {"context": context, "user_id": user_id, "course_id": course_id} diff --git a/enterprise/settings/common.py b/enterprise/settings/common.py index 550ad7450..94bf0a14a 100644 --- a/enterprise/settings/common.py +++ b/enterprise/settings/common.py @@ -16,6 +16,10 @@ "fail_silently": False, "pipeline": ["enterprise.filters.dashboard.DashboardContextEnricher"], }, + "org.openedx.learning.grade.context.requested.v1": { + "fail_silently": False, + "pipeline": ["enterprise.filters.grades.GradeEventContextEnricher"], + }, } diff --git a/tests/filters/__init__.py b/tests/filters/__init__.py index e69de29bb..5a5f3f9ac 100644 --- a/tests/filters/__init__.py +++ b/tests/filters/__init__.py @@ -0,0 +1 @@ +"""Tests for enterprise filter pipeline steps.""" diff --git a/tests/filters/test_grades.py b/tests/filters/test_grades.py new file mode 100644 index 000000000..e6a3261f9 --- /dev/null +++ b/tests/filters/test_grades.py @@ -0,0 +1,73 @@ +""" +Tests for enterprise.filters.grades pipeline step. +""" +import uuid +from unittest.mock import patch + +from django.test import TestCase + +from enterprise.filters.grades import GradeEventContextEnricher + + +class TestGradeEventContextEnricher(TestCase): + """ + Tests for GradeEventContextEnricher pipeline step. + """ + + def _make_step(self): + return GradeEventContextEnricher( + filter_type="org.openedx.learning.grade.context.requested.v1", + running_pipeline=[], + ) + + @patch("enterprise.models.EnterpriseCourseEnrollment.get_enterprise_uuids_with_user_and_course") + def test_enriches_context_when_enterprise_enrollment_found(self, mock_get_uuids): + """ + When an enterprise course enrollment exists, enterprise_uuid is added to context. + """ + enterprise_uuid = uuid.uuid4() + mock_get_uuids.return_value = [enterprise_uuid] + + step = self._make_step() + context = {"org": "TestOrg", "course_id": "course-v1:org+course+run"} + result = step.run_filter(context=context, user_id=7, course_id="course-v1:org+course+run") + + assert result == { + "context": {**context, "enterprise_uuid": str(enterprise_uuid)}, + "user_id": 7, + "course_id": "course-v1:org+course+run", + } + mock_get_uuids.assert_called_once_with(7, "course-v1:org+course+run") + + @patch("enterprise.models.EnterpriseCourseEnrollment.get_enterprise_uuids_with_user_and_course") + def test_returns_unchanged_context_when_no_enterprise_enrollment(self, mock_get_uuids): + """ + When no enterprise course enrollment exists, context is returned unchanged. + """ + mock_get_uuids.return_value = [] + + step = self._make_step() + context = {"org": "TestOrg"} + result = step.run_filter(context=context, user_id=99, course_id="course-v1:org+course+run") + + assert result == { + "context": context, + "user_id": 99, + "course_id": "course-v1:org+course+run", + } + assert "enterprise_uuid" not in result["context"] + + @patch("enterprise.models.EnterpriseCourseEnrollment.get_enterprise_uuids_with_user_and_course") + def test_uses_first_uuid_when_multiple_enrollments(self, mock_get_uuids): + """ + When multiple enterprise enrollments exist, only the first UUID is used. + """ + first_uuid = uuid.uuid4() + second_uuid = uuid.uuid4() + mock_get_uuids.return_value = [first_uuid, second_uuid] + + step = self._make_step() + context = {} + result = step.run_filter(context=context, user_id=1, course_id="course-v1:x+y+z") + + assert result["context"]["enterprise_uuid"] == str(first_uuid)