Skip to content
Open
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
9 changes: 8 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ Change Log
Unreleased
__________

* Added support for complex types in dictionaries and lists.

[10.5.1] - 2026-01-26
---------------------

Fixed
~~~~~

* Fixed circular reference error in ``PERSISTENT_GRADE_SUMMARY_CHANGED`` event serialization by adding converters to handle MongoDB BSON objects (``FixedOffset`` timezone and ``ObjectId``) in ``PersistentCourseGradeData`` and ``CourseData``.

[10.5.0] - 2025-08-19
---------------------
Expand Down
2 changes: 1 addition & 1 deletion openedx_events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
more information about the project.
"""

__version__ = "10.5.0"
__version__ = "10.5.1"
58 changes: 53 additions & 5 deletions openedx_events/learning/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,54 @@
from opaque_keys.edx.keys import CourseKey, UsageKey


def _convert_objectid_to_str(value):
"""
Convert MongoDB ObjectId to string if needed.

This converter handles cases where MongoDB BSON ObjectId objects
are passed instead of strings, preventing JSON serialization errors.

Args:
value: The value to convert (str or ObjectId)

Returns:
str: String representation of the value
"""
# Check if it's a BSON ObjectId without importing bson
# (to avoid adding hard dependency on pymongo)
if value is not None and hasattr(value, '__class__') and value.__class__.__name__ == 'ObjectId':
return str(value)
return value


def _normalize_datetime_timezone(value):
"""
Normalize datetime timezone to avoid BSON FixedOffset circular reference.

MongoDB's bson.tz_util.FixedOffset objects cause circular reference errors
during JSON serialization. This converter replaces them with standard UTC.

Args:
value: datetime object (possibly with BSON FixedOffset timezone)

Returns:
datetime: datetime with standard timezone or None
"""
if value is None:
return None

# Check if tzinfo is a BSON FixedOffset (causes circular reference)
if value.tzinfo is not None:
tzinfo_class = value.tzinfo.__class__.__name__
if tzinfo_class == 'FixedOffset':
# Convert to UTC to avoid circular reference
# The FixedOffset represents a fixed UTC offset, so convert to standard UTC
from datetime import timezone as dt_timezone
return value.replace(tzinfo=dt_timezone.utc)

return value


@attr.s(frozen=True)
class UserNonPersonalData:
"""
Expand Down Expand Up @@ -75,8 +123,8 @@ class CourseData:

course_key = attr.ib(type=CourseKey)
display_name = attr.ib(type=str, factory=str)
start = attr.ib(type=datetime, default=None)
end = attr.ib(type=datetime, default=None)
start = attr.ib(type=datetime, default=None, converter=_normalize_datetime_timezone)
end = attr.ib(type=datetime, default=None, converter=_normalize_datetime_timezone)


@attr.s(frozen=True)
Expand Down Expand Up @@ -252,12 +300,12 @@ class PersistentCourseGradeData:

user_id = attr.ib(type=int)
course = attr.ib(type=CourseData)
course_edited_timestamp = attr.ib(type=datetime)
course_version = attr.ib(type=str)
course_edited_timestamp = attr.ib(type=datetime, converter=_normalize_datetime_timezone)
course_version = attr.ib(type=str, converter=_convert_objectid_to_str)
grading_policy_hash = attr.ib(type=str)
percent_grade = attr.ib(type=float)
letter_grade = attr.ib(type=str)
passed_timestamp = attr.ib(type=datetime)
passed_timestamp = attr.ib(type=datetime, converter=_normalize_datetime_timezone)


@attr.s(frozen=True)
Expand Down
247 changes: 247 additions & 0 deletions tests/test_bson_converters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
"""
Tests for BSON FixedOffset and ObjectId converters in PersistentCourseGradeData.

These tests verify that the converters properly handle MongoDB BSON objects
that cause circular reference errors during JSON serialization.
"""
import json
from datetime import datetime, timezone
from bson import ObjectId
from bson.tz_util import FixedOffset
from opaque_keys.edx.locator import CourseLocator

from openedx_events.learning.data import CourseData, PersistentCourseGradeData


class TestBSONFixedOffsetConverter:
"""Test that BSON FixedOffset timezones are converted to standard UTC."""

def test_persistent_grade_with_fixedoffset_timezone(self):
"""Test course_edited_timestamp with BSON FixedOffset timezone."""
# Create a BSON FixedOffset timezone (simulating MongoDB data)
bson_timezone = FixedOffset(0, 'UTC') # 0 offset = UTC
timestamp_with_bson = datetime(2025, 1, 20, 10, 18, 1, 213000, tzinfo=bson_timezone)

course_data = CourseData(
course_key=CourseLocator('HP', 'HPGG03.en', '2T2023', None, None),
display_name='Test Course',
)

grade_data = PersistentCourseGradeData(
user_id=68293694,
course=course_data,
course_edited_timestamp=timestamp_with_bson, # BSON FixedOffset
course_version='test-version',
grading_policy_hash='kzLSFp+s4RiZlW0/QfqsXi5kqOc=',
percent_grade=0.89,
letter_grade='Pass',
passed_timestamp=datetime.now(timezone.utc)
)

# Verify timezone was converted
assert grade_data.course_edited_timestamp.tzinfo == timezone.utc
assert grade_data.course_edited_timestamp.tzinfo.__class__.__name__ != 'FixedOffset'

print("✅ BSON FixedOffset converted to timezone.utc")

def test_passed_timestamp_with_fixedoffset(self):
"""Test passed_timestamp with BSON FixedOffset timezone."""
bson_timezone = FixedOffset(0, 'UTC')
passed_timestamp = datetime(2026, 1, 23, 16, 24, 41, 912992, tzinfo=bson_timezone)

course_data = CourseData(
course_key=CourseLocator('HP', 'HPGG03.en', '2T2023', None, None),
display_name='Test Course',
)

grade_data = PersistentCourseGradeData(
user_id=68293694,
course=course_data,
course_edited_timestamp=datetime.now(timezone.utc),
course_version='test-version',
grading_policy_hash='hash123',
percent_grade=0.89,
letter_grade='Pass',
passed_timestamp=passed_timestamp # BSON FixedOffset
)

# Verify timezone was converted
assert grade_data.passed_timestamp.tzinfo == timezone.utc
assert grade_data.passed_timestamp.tzinfo.__class__.__name__ != 'FixedOffset'

print("✅ passed_timestamp BSON FixedOffset converted to timezone.utc")

def test_course_data_start_end_with_fixedoffset(self):
"""Test CourseData start/end with BSON FixedOffset timezone."""
bson_timezone = FixedOffset(0, 'UTC')
start_time = datetime(2025, 1, 1, tzinfo=bson_timezone)
end_time = datetime(2025, 12, 31, tzinfo=bson_timezone)

course_data = CourseData(
course_key=CourseLocator('HP', 'HPGG03.en', '2T2023', None, None),
display_name='Test Course',
start=start_time, # BSON FixedOffset
end=end_time # BSON FixedOffset
)

# Verify timezones were converted
assert course_data.start.tzinfo == timezone.utc
assert course_data.end.tzinfo == timezone.utc
assert course_data.start.tzinfo.__class__.__name__ != 'FixedOffset'
assert course_data.end.tzinfo.__class__.__name__ != 'FixedOffset'

print("✅ CourseData start/end BSON FixedOffset converted to timezone.utc")

def test_json_serialization_with_converted_timezone(self):
"""Test that converted timezone is JSON serializable (no circular reference)."""
bson_timezone = FixedOffset(0, 'UTC')
timestamp = datetime(2025, 1, 20, 10, 18, 1, 213000, tzinfo=bson_timezone)

course_data = CourseData(
course_key=CourseLocator('HP', 'HPGG03.en', '2T2023', None, None),
display_name='Test Course',
)

grade_data = PersistentCourseGradeData(
user_id=68293694,
course=course_data,
course_edited_timestamp=timestamp,
course_version='test-version',
grading_policy_hash='hash123',
percent_grade=0.89,
letter_grade='Pass',
passed_timestamp=datetime.now(timezone.utc)
)

# This should NOT raise "ValueError: Circular reference detected"
try:
# Attempt to serialize the datetime
json_data = json.dumps({
'timestamp': grade_data.course_edited_timestamp.isoformat()
})
assert json_data is not None
print("✅ JSON serialization successful - no circular reference!")
except ValueError as e:
if 'Circular reference' in str(e):
raise AssertionError("Circular reference error - converter failed!")
raise


class TestObjectIdConverter:
"""Test that MongoDB ObjectId is converted to string."""

def test_course_version_with_objectid(self):
"""Test course_version with MongoDB ObjectId."""
mongo_oid = ObjectId('678e22d9035e75dd65e56c28')

course_data = CourseData(
course_key=CourseLocator('HP', 'HPGG03.en', '2T2023', None, None),
display_name='Test Course',
)

grade_data = PersistentCourseGradeData(
user_id=68293694,
course=course_data,
course_edited_timestamp=datetime.now(timezone.utc),
course_version=mongo_oid, # ObjectId, not string
grading_policy_hash='hash123',
percent_grade=0.89,
letter_grade='Pass',
passed_timestamp=datetime.now(timezone.utc)
)

# Verify ObjectId was converted to string
assert isinstance(grade_data.course_version, str)
assert grade_data.course_version == '678e22d9035e75dd65e56c28'

print("✅ ObjectId converted to string")

def test_course_version_string_passthrough(self):
"""Test that regular strings pass through unchanged."""
course_data = CourseData(
course_key=CourseLocator('HP', 'HPGG03.en', '2T2023', None, None),
display_name='Test Course',
)

grade_data = PersistentCourseGradeData(
user_id=68293694,
course=course_data,
course_edited_timestamp=datetime.now(timezone.utc),
course_version='regular-string-version', # Regular string
grading_policy_hash='hash123',
percent_grade=0.89,
letter_grade='Pass',
passed_timestamp=datetime.now(timezone.utc)
)

# Verify string passes through unchanged
assert grade_data.course_version == 'regular-string-version'

print("✅ Regular string passes through unchanged")


class TestProductionScenario:
"""Test the exact production scenario from the error log."""

def test_production_error_scenario(self):
"""
Replicate the exact production error scenario:
- BSON FixedOffset in course_edited_timestamp
- ObjectId in course_version
"""
# Exact data from production error log
bson_timezone = FixedOffset(0, 'UTC')
course_edited = datetime(2025, 1, 20, 10, 18, 1, 213000, tzinfo=bson_timezone)
passed_timestamp = datetime(2026, 1, 23, 16, 24, 41, 912992, tzinfo=timezone.utc)
course_version_oid = ObjectId('678e22d9035e75dd65e56c28')

course_data = CourseData(
course_key=CourseLocator('HP', 'HPGG03.en', '2T2023', None, None),
display_name='',
start=None,
end=None
)

# This is the exact data structure that was causing the error
grade_data = PersistentCourseGradeData(
user_id=68293694,
course=course_data,
course_edited_timestamp=course_edited, # BSON FixedOffset
course_version=course_version_oid, # ObjectId
grading_policy_hash='kzLSFp+s4RiZlW0/QfqsXi5kqOc=',
percent_grade=0.89,
letter_grade='Pass',
passed_timestamp=passed_timestamp
)

# Verify both conversions worked
assert grade_data.course_edited_timestamp.tzinfo == timezone.utc
assert isinstance(grade_data.course_version, str)
assert grade_data.course_version == '678e22d9035e75dd65e56c28'

# Verify JSON serialization works (the ultimate test)
try:
test_dict = {
'user_id': grade_data.user_id,
'course_edited_timestamp': grade_data.course_edited_timestamp.isoformat(),
'course_version': grade_data.course_version,
'percent_grade': grade_data.percent_grade,
'letter_grade': grade_data.letter_grade,
}
json_output = json.dumps(test_dict)
assert json_output is not None
print("✅ Production scenario: Successfully serialized to JSON!")
print(f" JSON output: {json_output[:100]}...")
except ValueError as e:
if 'Circular reference' in str(e):
raise AssertionError("FAILED: Circular reference still occurring!")
raise


if __name__ == '__main__':
import pytest
import sys

# Run tests with verbose output
exit_code = pytest.main([__file__, '-v', '-s'])
sys.exit(exit_code)
Loading