Merge pull request #36845 from raccoongang/rg/axm-course-catalog-extend-courseware-api

feat: [FC-86] extend courseware api with new fields
This commit is contained in:
Feanil Patel
2025-09-04 09:46:25 -04:00
committed by GitHub
4 changed files with 505 additions and 20 deletions

View File

@@ -623,4 +623,4 @@ def uses_shib(course):
Returns a boolean indicating if Shibboleth authentication is set for this course.
"""
return course.enrollment_domain and course.enrollment_domain.startswith(settings.SHIBBOLETH_DOMAIN_PREFIX)
return bool(course.enrollment_domain and course.enrollment_domain.startswith(settings.SHIBBOLETH_DOMAIN_PREFIX))

View File

@@ -73,10 +73,20 @@ class CourseProgramSerializer(serializers.Serializer): # lint-amnesty, pylint:
}
class PrerequisiteCourseSerializer(serializers.Serializer):
"""
Serializer for prerequisite course data with the serialized course key and display name.
"""
key = serializers.CharField()
display = serializers.CharField()
class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Serializer for Course objects providing minimal data about the course.
Compare this with CourseDetailSerializer.
For detailed information about what each field is for, see the docstring of the
CoursewareInformation class.
"""
access_expiration = serializers.DictField()
@@ -115,6 +125,40 @@ class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract-
is_integrity_signature_enabled = serializers.BooleanField()
user_needs_integrity_signature = serializers.BooleanField()
learning_assistant_enabled = serializers.BooleanField()
show_courseware_link = serializers.BooleanField()
is_course_full = serializers.BooleanField()
can_enroll = serializers.BooleanField()
invitation_only = serializers.BooleanField()
is_shib_course = serializers.BooleanField()
allow_anonymous = serializers.BooleanField()
ecommerce_checkout = serializers.BooleanField()
single_paid_mode = serializers.DictField()
ecommerce_checkout_link = AbsoluteURLField()
course_image_urls = serializers.ListField(
child=serializers.CharField(),
allow_empty=True,
default=list,
)
start_date_is_still_default = serializers.BooleanField()
advertised_start = serializers.CharField()
course_price = serializers.CharField()
pre_requisite_courses = serializers.ListField(
child=PrerequisiteCourseSerializer(),
allow_empty=True,
default=list,
)
about_sidebar_html = serializers.CharField(
allow_blank=True,
allow_null=True,
default=None,
)
display_number_with_default = serializers.CharField()
display_org_with_default = serializers.CharField()
overview = serializers.CharField(
allow_blank=True,
allow_null=True,
default=None,
)
def __init__(self, *args, **kwargs):
"""

View File

@@ -15,7 +15,7 @@ from django.test import override_settings
from django.test.client import RequestFactory
from edx_django_utils.cache import TieredCache
from edx_toggles.toggles.testutils import override_waffle_flag
from edx_toggles.toggles.testutils import override_waffle_flag, override_waffle_switch
from xmodule.data import CertificatesDisplayBehaviors
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
@@ -45,7 +45,7 @@ from common.djangoapps.student.roles import CourseInstructorRole
from common.djangoapps.student.tests.factories import CourseEnrollmentCelebrationFactory, UserFactory
from openedx.core.djangoapps.agreements.api import create_integrity_signature
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
User = get_user_model()
@@ -611,3 +611,257 @@ class CelebrationApiTestViews(BaseCoursewareTests, MasqueradeMixin):
# make sure they didn't change during masquerade attempt
assert celebration.celebrate_first_section
assert not celebration.celebrate_weekly_goal
@ddt.ddt
@skip_unless_lms # If run in CMS, the tests fail as the courseware_api.views module contains imports from the LMS.
class CoursewareMetaTestViews(BaseCoursewareTests):
"""
Tests for the CoursewareMeta class
"""
def setUp(self):
super().setUp()
self.course_enrollment = CourseEnrollment.enroll(self.user, self.course.id, 'audit')
self.request = RequestFactory().get(self.url)
def create_courseware_meta(self, user=None):
"""
Helper method to create CoursewareMeta instance
"""
from openedx.core.djangoapps.courseware_api.views import CoursewareMeta
user = user or self.user
self.request.user = user
return CoursewareMeta(self.course.id, self.request, username=user.username)
@ddt.data(True, False)
def test_is_course_full_property(self, is_course_full):
"""
Test is_course_full property
"""
with mock.patch(
'openedx.core.djangoapps.courseware_api.views.CourseEnrollment.objects.is_course_full'
) as mock_is_course_full:
mock_is_course_full.return_value = is_course_full
meta = self.create_courseware_meta()
assert meta.is_course_full is is_course_full
@ddt.data(True, False)
def test_invitation_only_property(self, invitation_only):
"""
Test invitation_only property
"""
with override_settings(COURSES_INVITE_ONLY=invitation_only):
meta = self.create_courseware_meta()
assert meta.invitation_only is invitation_only
@ddt.data(True, False)
@mock.patch(
'openedx.core.djangoapps.courseware_api.views.get_course_about_section', new_callable=mock.PropertyMock
)
def test_about_sidebar_html_property(self, waffle_enabled, mock_get_course_about_section):
"""
Test about_sidebar_html property with different waffle settings
"""
mock_get_course_about_section.return_value = '<div>About Course</div>'
with override_waffle_switch(ENABLE_COURSE_ABOUT_SIDEBAR_HTML, active=waffle_enabled):
meta = self.create_courseware_meta()
if waffle_enabled:
assert meta.about_sidebar_html == '<div>About Course</div>'
else:
assert meta.about_sidebar_html is None
@ddt.ddt
@skip_unless_lms
class CoursewareMetaAPIResponseTestViews(BaseCoursewareTests):
"""
Tests for API response fields returned by CoursewareMeta through the API endpoint
"""
def setUp(self):
super().setUp()
CourseEnrollment.enroll(self.user, self.course.id, 'audit')
def test_api_returns_show_courseware_link_field(self):
"""
Test that API response contains show_courseware_link field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'show_courseware_link' in response.data
assert isinstance(response.data['show_courseware_link'], bool)
def test_api_returns_is_course_full_field(self):
"""
Test that API response contains is_course_full field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'is_course_full' in response.data
assert isinstance(response.data['is_course_full'], bool)
def test_api_returns_can_enroll_field(self):
"""
Test that API response contains can_enroll field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'can_enroll' in response.data
assert isinstance(response.data['can_enroll'], bool)
def test_api_returns_invitation_only_field(self):
"""
Test that API response contains invitation_only field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'invitation_only' in response.data
assert isinstance(response.data['invitation_only'], bool)
def test_api_returns_is_shib_course_field(self):
"""
Test that API response contains is_shib_course field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'is_shib_course' in response.data
assert isinstance(response.data['is_shib_course'], bool)
def test_api_returns_allow_anonymous_field(self):
"""
Test that API response contains allow_anonymous field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'allow_anonymous' in response.data
assert isinstance(response.data['allow_anonymous'], bool)
def test_api_returns_ecommerce_checkout_field(self):
"""
Test that API response contains ecommerce_checkout field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'ecommerce_checkout' in response.data
assert isinstance(response.data['ecommerce_checkout'], bool)
def test_api_returns_single_paid_mode_field(self):
"""
Test that API response contains single_paid_mode field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'single_paid_mode' in response.data
assert isinstance(response.data['single_paid_mode'], dict)
def test_api_returns_ecommerce_checkout_link_field(self):
"""
Test that API response contains ecommerce_checkout_link field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'ecommerce_checkout_link' in response.data
checkout_link = response.data['ecommerce_checkout_link']
assert isinstance(checkout_link, str) or checkout_link is None
def test_api_returns_course_image_urls_field(self):
"""
Test that API response contains course_image_urls field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'course_image_urls' in response.data
assert isinstance(response.data['course_image_urls'], list)
def test_api_returns_start_date_is_still_default_field(self):
"""
Test that API response contains start_date_is_still_default field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'start_date_is_still_default' in response.data
assert isinstance(response.data['start_date_is_still_default'], bool)
def test_api_returns_advertised_start_field(self):
"""
Test that API response contains advertised_start field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'advertised_start' in response.data
advertised_start = response.data['advertised_start']
assert isinstance(advertised_start, str) or advertised_start is None
def test_api_returns_course_price_field(self):
"""
Test that API response contains course_price field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'course_price' in response.data
assert isinstance(response.data['course_price'], str)
def test_api_returns_pre_requisite_courses_field(self):
"""
Test that API response contains pre_requisite_courses field
"""
response = self.client.get(self.url)
assert response.status_code == 200
assert 'pre_requisite_courses' in response.data
assert isinstance(response.data['pre_requisite_courses'], list)
@ddt.data(True, False)
@mock.patch(
'openedx.core.djangoapps.courseware_api.views.get_course_about_section', new_callable=mock.PropertyMock
)
def test_api_about_sidebar_html_with_waffle(self, waffle_enabled, mock_get_course_about_section):
"""
Test API returns correct about_sidebar_html value based on waffle flag
"""
with override_waffle_switch(ENABLE_COURSE_ABOUT_SIDEBAR_HTML, active=waffle_enabled):
mock_get_course_about_section.return_value = '<div>About Course</div>'
response = self.client.get(self.url)
assert response.status_code == 200
assert 'about_sidebar_html' in response.data
if waffle_enabled:
assert response.data['about_sidebar_html'] == '<div>About Course</div>'
else:
assert response.data['about_sidebar_html'] is None
@ddt.ddt
@skip_unless_lms
class CoursewareMetaIntegrationTestViews(BaseCoursewareTests):
"""
Integration tests for CoursewareMeta with different user states and course configurations
"""
@ddt.data(
('audit', False),
('verified', True),
('honor', True),
('professional', True),
)
@ddt.unpack
def test_enrollment_mode_affects_can_access_proctored_exams(self, enrollment_mode, expected_access):
"""
Test that enrollment mode affects proctored exam access in API response
"""
CourseEnrollment.enroll(self.user, self.course.id, enrollment_mode)
response = self.client.get(self.url)
assert response.status_code == 200
assert response.data['can_access_proctored_exams'] == expected_access
@mock.patch('openedx.core.djangoapps.courseware_api.views.check_public_access')
def test_public_course_affects_allow_anonymous(self, mock_check_public_access):
"""
Test that course visibility settings affect allow_anonymous field
"""
mock_check_public_access.return_value = ACCESS_GRANTED
response = self.client.get(self.url)
assert response.status_code == 200
assert response.data['allow_anonymous'] is True

View File

@@ -25,15 +25,22 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from xmodule.modulestore.search import path_to_location
from xmodule.x_module import PUBLIC_VIEW, STUDENT_VIEW
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.models import CourseMode, get_course_prices
from common.djangoapps.util.views import expose_header
from lms.djangoapps.edxnotes.helpers import is_feature_enabled
from lms.djangoapps.certificates.api import get_certificate_url
from lms.djangoapps.certificates.models import GeneratedCertificate
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.course_api.api import course_detail
from lms.djangoapps.course_goals.models import UserActivity
from lms.djangoapps.course_goals.api import get_course_goal
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.access_utils import check_public_access
from lms.djangoapps.courseware.courses import (
get_course_about_section,
get_course_with_access,
get_permission_for_course_about,
)
from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs
from lms.djangoapps.courseware.entrance_exams import course_has_entrance_exam, user_has_passed_entrance_exam
@@ -43,19 +50,24 @@ from lms.djangoapps.courseware.masquerade import (
is_masquerading_as_non_audit_enrollment,
)
from lms.djangoapps.courseware.models import LastSeenCoursewareTimezone
from lms.djangoapps.courseware.permissions import VIEW_COURSEWARE
from lms.djangoapps.courseware.block_render import get_block_by_usage_id
from lms.djangoapps.courseware.toggles import course_exit_page_is_active
from lms.djangoapps.courseware.toggles import course_exit_page_is_active, course_is_invitation_only
from lms.djangoapps.courseware.views.views import get_cert_data
from lms.djangoapps.gating.api import get_entrance_exam_score, get_entrance_exam_usage_key
from lms.djangoapps.grades.api import CourseGradeFactory
from lms.djangoapps.instructor.enrollment import uses_shib
from common.djangoapps.util.milestones_helpers import get_prerequisite_courses_display
from lms.djangoapps.verify_student.services import IDVerificationService
from openedx.core.djangoapps.agreements.api import get_integrity_signature
from openedx.core.djangoapps.courseware_api.utils import get_celebrations_dict
from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
from openedx.core.lib.courses import get_course_by_id
from openedx.features.course_experience import ENABLE_COURSE_GOALS
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from openedx.features.course_duration_limits.access import get_access_expiration_data
from openedx.features.discounts.utils import generate_offer_data
@@ -64,6 +76,10 @@ from common.djangoapps.student.models import (
CourseEnrollmentCelebration,
LinkedInAddToProfileConfiguration
)
from xmodule.course_block import (
COURSE_VISIBILITY_PUBLIC,
COURSE_VISIBILITY_PUBLIC_OUTLINE,
)
from .serializers import CourseInfoSerializer
@@ -75,13 +91,13 @@ class CoursewareMeta:
def __init__(self, course_key, request, username=''):
self.request = request
self.overview = course_detail(
self.course_overview = course_detail(
self.request,
username or self.request.user.username,
course_key,
)
original_user_is_staff = has_access(self.request.user, 'staff', self.overview).has_access
original_user_is_staff = has_access(self.request.user, 'staff', self.course_overview).has_access
self.original_user_is_global_staff = self.request.user.is_staff
self.course_key = course_key
self.course = get_course_by_id(self.course_key)
@@ -91,12 +107,13 @@ class CoursewareMeta:
staff_access=original_user_is_staff,
)
self.request.user = self.effective_user
self.overview.bind_course_for_student(self.request)
self.course_overview.bind_course_for_student(self.request)
self.enrollment_object = CourseEnrollment.get_enrollment(self.effective_user, self.course_key,
select_related=['celebration', 'user__celebration'])
self.ecomm_service = EcommerceService()
def __getattr__(self, name):
return getattr(self.overview, name)
return getattr(self.course_overview, name)
@property
def enrollment(self):
@@ -113,11 +130,11 @@ class CoursewareMeta:
@property
def access_expiration(self):
return get_access_expiration_data(self.effective_user, self.overview)
return get_access_expiration_data(self.effective_user, self.course_overview)
@property
def offer(self):
return generate_offer_data(self.effective_user, self.overview)
return generate_offer_data(self.effective_user, self.course_overview)
@property
def content_type_gating_enabled(self):
@@ -140,8 +157,8 @@ class CoursewareMeta:
Return whether edxnotes is enabled and visible.
"""
return {
'enabled': is_feature_enabled(self.overview, self.effective_user),
'visible': self.overview.edxnotes_visibility,
'enabled': is_feature_enabled(self.course_overview, self.effective_user),
'visible': self.course_overview.edxnotes_visibility,
}
@property
@@ -214,12 +231,12 @@ class CoursewareMeta:
"""
return {
'entrance_exam_current_score': get_entrance_exam_score(
self.course_grade, get_entrance_exam_usage_key(self.overview),
self.course_grade, get_entrance_exam_usage_key(self.course_overview),
),
'entrance_exam_enabled': course_has_entrance_exam(self.overview),
'entrance_exam_id': self.overview.entrance_exam_id,
'entrance_exam_minimum_score_pct': self.overview.entrance_exam_minimum_score_pct,
'entrance_exam_passed': user_has_passed_entrance_exam(self.effective_user, self.overview),
'entrance_exam_enabled': course_has_entrance_exam(self.course_overview),
'entrance_exam_id': self.course_overview.entrance_exam_id,
'entrance_exam_minimum_score_pct': self.course_overview.entrance_exam_minimum_score_pct,
'entrance_exam_passed': user_has_passed_entrance_exam(self.effective_user, self.course_overview),
}
@property
@@ -271,7 +288,7 @@ class CoursewareMeta:
get_certificate_url(course_id=self.course_key, uuid=user_certificate.verify_uuid)
)
return linkedin_config.add_to_profile_url(
self.overview.display_name, user_certificate.mode, cert_url, certificate=user_certificate,
self.course_overview.display_name, user_certificate.mode, cert_url, certificate=user_certificate,
)
@property
@@ -369,6 +386,139 @@ class CoursewareMeta:
"""
return getattr(settings, 'LEARNING_ASSISTANT_AVAILABLE', False)
@property
def show_courseware_link(self):
"""
Returns a boolean representing whether the courseware link should be shown in the course details page.
"""
with modulestore().bulk_operations(self.course_key):
permission = get_permission_for_course_about()
course_with_access = get_course_with_access(self.request.user, permission, self.course_key)
return bool(
self.request.user.has_perm(VIEW_COURSEWARE, course_with_access)
or settings.FEATURES.get('ENABLE_LMS_MIGRATION')
)
@property
def is_course_full(self):
"""
Returns a boolean representing whether the course is full.
"""
return CourseEnrollment.objects.is_course_full(self.course)
@property
def can_enroll(self):
"""
Returns a boolean representing whether the user can enroll in the course.
"""
return bool(self.request.user.has_perm(ENROLL_IN_COURSE, self.course))
@property
def invitation_only(self):
"""
Returns a boolean representing whether the course is invitation only.
"""
return course_is_invitation_only(self.course)
@property
def is_shib_course(self):
"""
Returns a boolean representing whether the course is a Shibboleth course.
"""
return uses_shib(self.course)
@property
def allow_anonymous(self):
"""
Returns a boolean representing whether the course allows anonymous access.
"""
return bool(check_public_access(self.course, [COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE]))
@property
def ecommerce_checkout(self):
"""
Returns a boolean representing whether the course has an ecommerce checkout.
"""
return self.ecomm_service.is_enabled(self.request.user)
@property
def single_paid_mode(self):
"""
Returns a dict representing the single paid mode for the course, if it exists.
"""
modes = CourseMode.modes_for_course_dict(self.course_key)
single_paid_mode = {}
if self.ecommerce_checkout:
if len(modes) == 1 and list(modes.values())[0].min_price:
single_paid_mode = list(modes.values())[0]
else:
# have professional ignore other modes for historical reasons
single_paid_mode = modes.get(CourseMode.PROFESSIONAL)
return single_paid_mode
@property
def ecommerce_checkout_link(self):
"""
Returns the ecommerce checkout link for the course.
"""
if self.single_paid_mode and self.single_paid_mode.sku:
return self.ecomm_service.get_checkout_page_url(
self.single_paid_mode.sku, course_run_keys=[self.course_key]
)
return None
@property
def course_image_urls(self):
"""
Returns a list of course image URLs.
"""
return self.course_overview.image_urls
@property
def start_date_is_still_default(self):
"""
Returns a boolean indicating whether the course start date is still the default value.
"""
return self.course_overview.start_date_is_still_default
@property
def advertised_start(self):
"""
Returns the advertised start date of the course.
"""
return self.course_overview.advertised_start
@property
def course_price(self):
"""
Returns the course price, formatted with the currency symbol.
"""
_, course_price = get_course_prices(self.course)
return course_price
@property
def pre_requisite_courses(self):
"""
Returns a list of pre-requisite courses for the course.
"""
return get_prerequisite_courses_display(self.course)
@property
def about_sidebar_html(self):
"""
Returns the HTML content for the course about section.
"""
if ENABLE_COURSE_ABOUT_SIDEBAR_HTML.is_enabled():
return get_course_about_section(self.request, self.course, "about_sidebar_html")
return None
@property
def overview(self):
"""
Returns the overview HTML content for the course.
"""
return get_course_about_section(self.request, self.course, "overview")
@method_decorator(transaction.non_atomic_requests, name='dispatch')
class CoursewareInformation(RetrieveAPIView):
@@ -458,9 +608,46 @@ class CoursewareInformation(RetrieveAPIView):
* certificate_data: data regarding the effective user's certificate for the given course
* verify_identity_url: URL for a learner to verify their identity. Only returned for learners enrolled in a
verified mode. Will update to reverify URL if necessary.
* verification_status: The verification status of the effective user in the course. Possible values:
* 'none': No verification has been created for the user
* 'expired': The verification has expired
* 'approved': The verification has been approved
* 'pending': The verification is pending
* 'must_reverify': The user must reverify their identity
* linkedin_add_to_profile_url: URL to add the effective user's certificate to a LinkedIn Profile.
* user_needs_integrity_signature: Whether the user needs to sign the integrity agreement for the course
* learning_assistant_enabled: Whether the Xpert Learning Assistant is enabled for the requesting user
* show_courseware_link: Whether the courseware link should be shown in the course details page
* is_course_full: Whether the course is full
* can_enroll: Whether the user can enroll in the course
* invitation_only: Whether the course is invitation only
* is_shib_course: Whether the course is a Shibboleth course
* allow_anonymous: Whether the course allows anonymous access
* ecommerce_checkout: Whether the course has an ecommerce checkout
* single_paid_mode: An object representing the single paid mode for the course, if it exists
* sku: (str) The SKU for the single paid mode
* name: (str) The name of the single paid mode
* min_price: (str) The minimum price for the single paid mode, formatted with the currency symbol
* description: (str) The description of the single paid mode
* is_discounted: (bool) Whether the single paid mode is discounted
* ecommerce_checkout_link: The ecommerce checkout link for the course, if it exists
* course_image_urls: A list of course image URLs
* start_date_is_still_default: Whether the course start date is still the default value
* advertised_start: The advertised start date of the course
* course_price: The course price, formatted with the currency symbol
* pre_requisite_courses: A list of pre-requisite courses for the course
* about_sidebar_html: The HTML content for the course about section, if enabled
* display_number_with_default: The course number with the org name, if set
* display_org_with_default: The org name with the course number, if set
* content_type_gating_enabled: Whether the content type gating is enabled for the course
* show_calculator: Whether the calculator should be shown in the course details page
* can_access_proctored_exams: Whether the user is eligible to access proctored exams
* notes: An object containing note settings for the course
* enabled: Boolean indicating whether edxnotes feature is enabled for the course
* visible: Boolean indicating whether notes are visible in the course
* marketing_url: The marketing URL for the course
* overview: The overview HTML content for the course
* license: The license for the course
**Parameters:**