feat: extend courseware api with new fields

This commit is contained in:
Andrii
2025-05-28 16:34:59 +03:00
parent 334c0fee51
commit c43e1be4a8
4 changed files with 416 additions and 4 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

@@ -115,6 +115,34 @@ 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=serializers.CharField(),
allow_empty=True,
default=list,
)
sidebar_html_enabled = serializers.BooleanField()
course_about_section_html = serializers.CharField(
allow_blank=True,
allow_null=True,
default=None,
)
def __init__(self, *args, **kwargs):
"""

View File

@@ -11,11 +11,12 @@ import ddt
from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ImproperlyConfigured
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 +46,14 @@ 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.core.lib import ensure_lms
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
try:
ensure_lms()
from openedx.core.djangoapps.courseware_api.views import CoursewareMeta
except ImproperlyConfigured:
pass
User = get_user_model()
@@ -611,3 +619,229 @@ 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
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"""
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)
def test_sidebar_html_enabled_property(self, waffle_enabled):
"""Test sidebar_html_enabled property with different waffle settings"""
with override_waffle_switch(ENABLE_COURSE_ABOUT_SIDEBAR_HTML, active=waffle_enabled):
meta = self.create_courseware_meta()
assert meta.sidebar_html_enabled == waffle_enabled
@ddt.data(True, False)
@mock.patch(
'openedx.core.djangoapps.courseware_api.views.get_course_about_section', new_callable=mock.PropertyMock
)
def test_course_about_section_html_property(self, waffle_enabled, mock_get_course_about_section):
"""Test course_about_section_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.course_about_section_html == '<div>About Course</div>'
else:
assert meta.course_about_section_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)
def test_api_sidebar_html_enabled_with_waffle(self, waffle_enabled):
"""Test API returns correct sidebar_html_enabled value based on waffle flag"""
with override_waffle_switch(ENABLE_COURSE_ABOUT_SIDEBAR_HTML, active=waffle_enabled):
response = self.client.get(self.url)
assert response.status_code == 200
assert 'sidebar_html_enabled' in response.data
assert response.data['sidebar_html_enabled'] == waffle_enabled
@ddt.data(True, False)
@mock.patch(
'openedx.core.djangoapps.courseware_api.views.get_course_about_section', new_callable=mock.PropertyMock
)
def test_api_course_about_section_html_with_waffle(self, waffle_enabled, mock_get_course_about_section):
"""Test API returns correct course_about_section_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 'course_about_section_html' in response.data
if waffle_enabled:
assert response.data['course_about_section_html'] == '<div>About Course</div>'
else:
assert response.data['course_about_section_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
@@ -94,6 +110,7 @@ class CoursewareMeta:
self.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)
@@ -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.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.overview.start_date_is_still_default
@property
def advertised_start(self):
"""
Returns the advertised start date of the course.
"""
return self.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 sidebar_html_enabled(self):
"""
Returns a boolean indicating whether the sidebar HTML is enabled for the course.
"""
return ENABLE_COURSE_ABOUT_SIDEBAR_HTML.is_enabled()
@property
def course_about_section_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
@method_decorator(transaction.non_atomic_requests, name='dispatch')
class CoursewareInformation(RetrieveAPIView):