Skip to content

Commit

Permalink
feat: add base problem filter
Browse files Browse the repository at this point in the history
Beased on NELC integration guide
https://edunext.atlassian.net/browse/FUTUREX-251
  • Loading branch information
andrey-canon committed Nov 27, 2023
1 parent 2a0ca30 commit 4ff6a20
Show file tree
Hide file tree
Showing 4 changed files with 304 additions and 4 deletions.
147 changes: 146 additions & 1 deletion eox_nelp/openedx_filters/tests/xapi/tests_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@
Classes:
XApiActorFilterTestCase: Tests cases for XApiActorFilter filter class.
XApiBaseEnrollmentFilterTestCase: Test cases for XApiBaseEnrollmentFilter filter class.
XApiBaseProblemsFilterTestCase: Test cases for XXApiBaseProblemsFilter filter class.
"""
from ddt import data, ddt
from django.contrib.auth import get_user_model
from django.test import TestCase
from mock import Mock, patch
from tincan import Activity, ActivityDefinition, Agent, LanguageMap

from eox_nelp.openedx_filters.xapi.filters import DEFAULT_LANGUAGE, XApiActorFilter, XApiBaseEnrollmentFilter
from eox_nelp.edxapp_wrapper.modulestore import modulestore
from eox_nelp.openedx_filters.xapi.filters import (
DEFAULT_LANGUAGE,
XApiActorFilter,
XApiBaseEnrollmentFilter,
XApiBaseProblemsFilter,
)

User = get_user_model()

Expand Down Expand Up @@ -155,3 +163,140 @@ def test_invalid_language(self, get_course_mock):
self.assertEqual({DEFAULT_LANGUAGE: course["display_name"]}, returned_activity.definition.name)
self.assertEqual({DEFAULT_LANGUAGE: course["short_description"]}, returned_activity.definition.description)
get_course_mock.assert_called_once_with("course-v1:edx+CS105+2023-T3")


@ddt
class XApiBaseProblemsFilterTestCase(TestCase):
"""Test class for XApiBaseProblemsFilterr filter class."""

def setUp(self):
"""Setup common conditions for every test case"""
self.default_values = {
"display_name": "testing-course",
"data.problem_id": "block-v1:edx+CS105+2023-T3+type@problem+block@0221040b086c4618b6b2b2a554558",
"course_id": "course-v1:edx+CS105+2023-T3",
}
self.filter = XApiBaseProblemsFilter(
filter_type="event_routing_backends.processors.xapi.problem_interaction_events.base_problems.get_object",
running_pipeline=["eox_nelp.openedx_filters.xapi.filters.XApiBaseProblemsFilter"],
)
self.transformer = Mock()
self.transformer.event = {"name": "edx.grades.problem.submitted"}
self.transformer.get_data.side_effect = lambda x: self.default_values[x]
self.transformer._get_submission.return_value = {} # pylint: disable=protected-access
self.course = {"language": "ar"}
self.activity = Activity(
id="https://example.com/xblock/block-v1:edx+CS105+2023-T3+type@problem+block@0221040b086c4618b6b2b2a554558",
definition=ActivityDefinition(
type="http://adlnet.gov/expapi/activities/question",
name=LanguageMap(en="old-testing-course"),
),
)

item = Mock()
item.markdown = ""
modulestore.return_value.get_item.return_value = item

def tearDown(self):
"""Restore mocks' state"""
modulestore.reset_mock()
self.transformer.reset_mock()

@data(None, "")
@patch("eox_nelp.openedx_filters.xapi.filters.get_course_from_id")
def test_invalid_display_name(self, display_name, get_course_mock): # pylint: disable=unused-argument
""" Test case when display_name is None or an empty string.
Expected behavior:
- Definition name wasn't updated.
"""
course_name = "testing-course"
get_course_mock.return_value = self.course

# Set results of get_data method.
self.default_values["display_name"] = display_name
self.transformer.get_data.side_effect = lambda x: self.default_values[x]

# Set input arguments.
self.activity.definition.name = LanguageMap(en=course_name)

returned_activity = self.filter.run_filter(transformer=self.transformer, result=self.activity)["result"]

self.assertEqual({DEFAULT_LANGUAGE: course_name}, returned_activity.definition.name)

@patch("eox_nelp.openedx_filters.xapi.filters.get_course_from_id")
def test_valid_display_name(self, get_course_mock):
""" Test case when display_name is found and valid.
Expected behavior:
- Definition name has been updated.
"""
get_course_mock.return_value = self.course

returned_activity = self.filter.run_filter(transformer=self.transformer, result=self.activity)["result"]

self.assertEqual(
{self.course['language']: self.default_values["display_name"]},
returned_activity.definition.name,
)

@patch("eox_nelp.openedx_filters.xapi.filters.get_course_from_id")
def test_update_description(self, get_course_mock):
""" Test case when item label is valid and the description is updated.
Expected behavior:
- Definition description has been updated.
"""
item = Mock()
label = "This is a great label"
item.markdown = f">>{label}<<"
modulestore.return_value.get_item.return_value = item
get_course_mock.return_value = self.course

returned_activity = self.filter.run_filter(transformer=self.transformer, result=self.activity)["result"]

self.assertEqual(
{self.course['language']: label},
returned_activity.definition.description,
)

@patch("eox_nelp.openedx_filters.xapi.filters.get_course_from_id")
def test_empty_label(self, get_course_mock):
""" Test case when the item markdown doesn't contain a label.
Expected behavior:
- Definition description is an empty dict.
"""
get_course_mock.return_value = self.course

returned_activity = self.filter.run_filter(transformer=self.transformer, result=self.activity)["result"]

self.assertEqual({}, returned_activity.definition.description)

@patch("eox_nelp.openedx_filters.xapi.filters.get_course_from_id")
def test_default_language(self, get_course_mock):
""" Test case when the course has no language or is not valid.
Expected behavior:
- Definition name has the default key language.
"""
get_course_mock.return_value = {}

returned_activity = self.filter.run_filter(transformer=self.transformer, result=self.activity)["result"]

self.assertEqual([DEFAULT_LANGUAGE], list(returned_activity.definition.name.keys()))

def test_invalid_event(self):
""" Test case when the event name is different from edx.grades.problem.submitted.
Expected behavior:
- Returned activity is the same input activity.
"""
self.transformer.event = {"name": "other-event"}
activity = Activity(
id="empty-activity",
)

returned_activity = self.filter.run_filter(transformer=self.transformer, result=activity)["result"]

self.assertEqual(activity, returned_activity)
57 changes: 56 additions & 1 deletion eox_nelp/openedx_filters/xapi/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
XApiBaseEnrollmentFilter: Updates enrollment object definition.
"""
from django.contrib.auth import get_user_model
from opaque_keys.edx.keys import UsageKey
from openedx_filters import PipelineStep
from tincan import Agent, LanguageMap

from eox_nelp.utils import extract_course_id_from_string, get_course_from_id
from eox_nelp.edxapp_wrapper.modulestore import modulestore
from eox_nelp.utils import extract_course_id_from_string, get_course_from_id, get_item_label

User = get_user_model()
DEFAULT_LANGUAGE = "en"
Expand All @@ -35,6 +37,7 @@ def run_filter(self, transformer, result): # pylint: disable=arguments-differ,
new Agent with email a name.
Arguments:
transformer <XApiTransformer>: Transformer instance.
result <Agent>: default Actor agent of event-routing-backends
Returns:
Expand Down Expand Up @@ -74,6 +77,7 @@ def run_filter(self, transformer, result): # pylint: disable=arguments-differ,
"""Modifies name and description attributes of the activity's definition.
Arguments:
transformer <XApiTransformer>: Transformer instance.
result <Activity>: Object activity for events related to enrollments.
Returns:
Expand All @@ -99,3 +103,54 @@ def run_filter(self, transformer, result): # pylint: disable=arguments-differ,
return {
"result": result
}


class XApiBaseProblemsFilter(PipelineStep):
"""This filter is designed to modify object attributes of an event which transformer class
is a child of BaseProblemsTransformer, this will add the description field and will change
the name based on the course language.
How to set:
OPEN_EDX_FILTERS_CONFIG = {
"event_routing_backends.processors.xapi.problem_interaction_events.base_problems.get_object": {
"pipeline": ["eox_nelp.openedx_filters.xapi.filters.XApiBaseProblemsFilter"],
"fail_silently": False,
},
}
"""

def run_filter(self, transformer, result): # pylint: disable=arguments-differ
"""Modifies name and description attributes of the activity's definition.
Arguments:
transformer <XApiTransformer>: Transformer instance.
result <Activity>: Object activity for events related to enrollments.
Returns:
Activity: Modified activity.
"""
# Following line is hard-coded since this logic has not been tested with other events.
if transformer.event["name"] != "edx.grades.problem.submitted":
return {
"result": result
}

display_name = transformer.get_data('display_name')

# Get label from module descriptor block.
usage_key = UsageKey.from_string(transformer.get_data("data.problem_id"))
item = modulestore().get_item(usage_key)
label = get_item_label(item)

# Get course languge block.
course = get_course_from_id(transformer.get_data('course_id'))
course_language = course.get("language") or DEFAULT_LANGUAGE # Set default value if language is not found

if display_name:
result.definition.name = LanguageMap({course_language: display_name})

result.definition.description = LanguageMap(**({course_language: label} if label else {}))

return {
"result": result
}
81 changes: 79 additions & 2 deletions eox_nelp/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
"""
from ddt import data, ddt
from django.test import TestCase
from mock import patch
from mock import Mock, patch
from opaque_keys.edx.keys import CourseKey

from eox_nelp.utils import extract_course_id_from_string, get_course_from_id
from eox_nelp.utils import extract_course_id_from_string, get_course_from_id, get_item_label


@ddt
Expand Down Expand Up @@ -99,3 +99,80 @@ def test_course_id_found(self, course_overviews_mock):

self.assertEqual(expected_course, course)
course_overviews_mock.assert_called_once_with([CourseKey.from_string(course_id)])


class GetItemLabelTestCase(TestCase):
"""Test class for the get_item_lable method."""

def test_item_does_not_have_markdown(self):
""" Test when the item doesn't have any markdown attribute.
Expected behavior:
- Returns an empty string
"""
fake_item = object()

label = get_item_label(fake_item)

self.assertEqual("", label)

def test_returns_label(self):
""" Test that the method finds and returns the label for the given item.
Expected behavior:
- Returns expected label
"""
expected_label = "This is a great label"
markdown = f"""
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean eleifend odio elit. Etiam lacus quam,
ultrices in ullamcorper a, ullamcorper eu ante. Nulla risus ante, congue sed tellus id, interdum accumsan
purus. Nunc malesuada eget >>{expected_label}<< urna placerat gravida. Aliquam justo nunc, porttitor nec
placerat non, gravida id arcu. Pellentesque condimentum sodales hendrerit.
"""
fake_item = Mock()
fake_item.markdown = markdown

label = get_item_label(fake_item)

self.assertEqual(expected_label, label)

def test_returns_first_label(self):
""" Test that the method returns the first label found.
Expected behavior:
- Returns expected label
"""
expected_label = "This is a great label"
wrong_label = "Wrong label"
markdown = f"""
Lorem ipsum dolor sit amet, >>{expected_label}<< consectetur adipiscing elit. Aenean eleifend odio elit.
Etiam lacus quam, >>{wrong_label}<< ultrices in ullamcorper a, ullamcorper eu ante. Nulla risus ante,
congue sed tellus id, interdum accumsan >>{wrong_label}<< purus. Nunc malesuada eget urna placerat
gravida. Aliquam justo nunc, porttitor nec >>{wrong_label}<< placerat non, gravida id arcu.
Pellentesque condimentum sodales hendrerit.
"""
fake_item = Mock()
fake_item.markdown = markdown

label = get_item_label(fake_item)

self.assertEqual(expected_label, label)

def test_label_not_found(self):
""" Test when the method doesn't find any labels.
Expected behavior:
- Returns an empty string
"""
markdown = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean eleifend odio elit. Etiam lacus quam,
ultrices in ullamcorper a, ullamcorper eu ante. Nulla risus ante, congue sed tellus id, interdum accumsan
purus. Nunc malesuada eget urna placerat gravida. Aliquam justo nunc, porttitor nec
placerat non, gravida id arcu. Pellentesque condimentum sodales hendrerit.
"""
fake_item = Mock()
fake_item.markdown = markdown

label = get_item_label(fake_item)

self.assertEqual("", label)
23 changes: 23 additions & 0 deletions eox_nelp/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,26 @@ def get_course_from_id(course_id):
return course_overviews[0]

raise ValueError(f"Course with id {course_id} does not exist.")


def get_item_label(item):
"""By definition the label of a Problem is the text between double greater and lees than
symbols, example, >>label<<, this method extracts and returns that information from the item
markdown value.
Arguments:
item <XModuleDescriptor>: This is a specification for an element of a course.
This case should be a problem.
Returns:
label <string>: Label data if it's found otherwise empty string.
"""
if not hasattr(item, "markdown"):
return ""

regex = re.compile(r'>>\s*(.*?)\s*<<')
matches = regex.search(item.markdown)

if matches:
return matches.group(1)

return ""

0 comments on commit 4ff6a20

Please sign in to comment.