diff --git a/openedx/core/djangoapps/enrollments/serializers.py b/openedx/core/djangoapps/enrollments/serializers.py index 9b64cc95caf8..96c3e0b0b279 100644 --- a/openedx/core/djangoapps/enrollments/serializers.py +++ b/openedx/core/djangoapps/enrollments/serializers.py @@ -4,6 +4,7 @@ import logging +from django.db.models import Prefetch from rest_framework import serializers from common.djangoapps.course_modes.models import CourseMode @@ -12,6 +13,17 @@ log = logging.getLogger(__name__) +def with_verified_mode_prefetch(queryset): + """Prefetch verified modes so enrollment serialization avoids per-row lookups.""" + return queryset.select_related("user", "course").prefetch_related( + Prefetch( + "course__modes", + queryset=CourseMode.objects.filter(mode_slug=CourseMode.VERIFIED), + to_attr="prefetched_verified_modes", + ) + ) + + class StringListField(serializers.CharField): """Custom Serializer for turning a comma delimited string into a list. @@ -69,7 +81,7 @@ def get_pacing_type(self, obj): class CourseEnrollmentSerializer(serializers.ModelSerializer): - """Serializes CourseEnrollment models + """Serializes CourseEnrollment models. Aggregates all data from the Course Enrollment table, and pulls in the serialization for the Course block and course modes, to give a complete representation of course enrollment. @@ -78,14 +90,64 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): course_details = CourseSerializer(source="course_overview") user = serializers.SerializerMethodField("get_username") + access_expiration_date = serializers.SerializerMethodField() + is_audit_with_expiring_upgrade = serializers.SerializerMethodField() def get_username(self, model): """Retrieves the username from the associated model.""" return model.username + def _get_verified_mode(self, obj): + """Retrieve the verified mode for this enrollment's course.""" + course_overview = obj.course_overview + if course_overview is None: + return None + + prefetched_verified_modes = getattr(course_overview, "prefetched_verified_modes", None) + if prefetched_verified_modes is not None: + return prefetched_verified_modes[0] if prefetched_verified_modes else None + + verified_mode_by_course = self.context.setdefault("verified_mode_by_course", {}) + if obj.course_id not in verified_mode_by_course: + verified_mode_by_course[obj.course_id] = CourseMode.objects.filter( + course_id=obj.course_id, + mode_slug=CourseMode.VERIFIED, + ).first() + + return verified_mode_by_course[obj.course_id] + + def get_access_expiration_date(self, obj): + """Return expiration date for audit enrollments.""" + + if obj.mode != CourseMode.AUDIT: + return None + + verified_mode = self._get_verified_mode(obj) + + if verified_mode and verified_mode.expiration_datetime: + return verified_mode.expiration_datetime.isoformat().replace('+00:00', 'Z') + + return None + + def get_is_audit_with_expiring_upgrade(self, obj): + """Return whether an audit enrollment has a verified upgrade expiration.""" + if obj.mode != CourseMode.AUDIT: + return False + + verified_mode = self._get_verified_mode(obj) + return bool(verified_mode and verified_mode.expiration_datetime) + class Meta: model = CourseEnrollment - fields = ("created", "mode", "is_active", "course_details", "user") + fields = ( + "created", + "mode", + "is_active", + "course_details", + "user", + "access_expiration_date", + "is_audit_with_expiring_upgrade", + ) lookup_field = "username" @@ -106,7 +168,7 @@ class Meta(CourseEnrollmentSerializer.Meta): class ModeSerializer(serializers.Serializer): # pylint: disable=abstract-method - """Serializes a course's 'Mode' tuples + """Serializes a course's 'Mode' tuples. Returns a serialized representation of the modes available for course enrollment. The course modes models are designed to return a tuple instead of the model object itself. This serializer diff --git a/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json b/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json index 65b7dfdfd06a..0830725c953c 100644 --- a/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json +++ b/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json @@ -9,14 +9,18 @@ "is_active": true, "mode": "honor", "user": "student1", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:e+d+X", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false } ] ], @@ -30,21 +34,27 @@ "is_active": true, "mode": "verified", "user": "staff", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:x+y+Z", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:x+y+Z", "is_active": true, "mode": "verified", "user": "student3", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false } ] ], @@ -59,14 +69,18 @@ "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:x+y+Z", "is_active": true, "mode": "verified", "user": "student3", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false } ] ], @@ -81,7 +95,9 @@ "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false } ] ], @@ -95,24 +111,29 @@ "is_active": true, "mode": "verified", "user": "staff", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:e+d+X", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:x+y+Z", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false } ] - ], [ { @@ -124,7 +145,9 @@ "is_active": true, "mode": "honor", "user": "student1", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false } ] ], @@ -138,21 +161,27 @@ "is_active": true, "mode": "honor", "user": "student1", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:e+d+X", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:x+y+Z", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false } ] ], @@ -167,7 +196,9 @@ "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false } ] ], @@ -179,35 +210,45 @@ "is_active": true, "mode": "honor", "user": "student1", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:e+d+X", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:x+y+Z", "is_active": true, "mode": "verified", "user": "student3", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:x+y+Z", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:x+y+Z", "is_active": true, "mode": "verified", "user": "staff", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false } ] ], @@ -221,35 +262,45 @@ "is_active": true, "mode": "honor", "user": "student1", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:e+d+X", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:x+y+Z", "is_active": true, "mode": "verified", "user": "staff", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:x+y+Z", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:x+y+Z", "is_active": true, "mode": "verified", "user": "student3", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false } ] ], @@ -264,14 +315,18 @@ "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false }, { "course_id": "course-v1:x+y+Z", "is_active": true, "mode": "honor", "user": "student2", - "created": "2018-01-01T00:00:01Z" + "created": "2018-01-01T00:00:01Z", + "access_expiration_date": null, + "is_audit_with_expiring_upgrade": false } ] ] diff --git a/openedx/core/djangoapps/enrollments/tests/test_views.py b/openedx/core/djangoapps/enrollments/tests/test_views.py index a6b34cbfc60b..2e83c90faffd 100644 --- a/openedx/core/djangoapps/enrollments/tests/test_views.py +++ b/openedx/core/djangoapps/enrollments/tests/test_views.py @@ -432,6 +432,38 @@ def test_check_enrollment(self): assert self.course.display_name_with_default == data['course_details']['course_name'] assert CourseMode.DEFAULT_MODE_SLUG == data['mode'] assert data['is_active'] + assert data['is_audit_with_expiring_upgrade'] is False + assert data['access_expiration_date'] is None + + def test_check_enrollment_with_audit_mode_and_access_expiration_date(self): + expiration_datetime = datetime.datetime(2026, 3, 17, 16, 12, 15, tzinfo=pytz.UTC) + expected_access_expiration_date = expiration_datetime.isoformat().replace('+00:00', 'Z') + + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=CourseMode.AUDIT, + mode_display_name=CourseMode.AUDIT, + ) + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=CourseMode.VERIFIED, + mode_display_name=CourseMode.VERIFIED, + expiration_datetime=expiration_datetime, + ) + + self.assert_enrollment_status(mode=CourseMode.AUDIT) + resp = self.client.get( + reverse( + 'courseenrollment', + kwargs={'username': self.user.username, "course_id": str(self.course.id)}, + ) + ) + + assert resp.status_code == status.HTTP_200_OK + data = json.loads(resp.content.decode('utf-8')) + assert data['mode'] == CourseMode.AUDIT + assert data['is_audit_with_expiring_upgrade'] is True + assert data['access_expiration_date'] == expected_access_expiration_date @ddt.data( (True, "True"),