diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.js b/common/lib/xmodule/xmodule/js/src/capa/display.js index 51902160c5..a7f0c49acc 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.js +++ b/common/lib/xmodule/xmodule/js/src/capa/display.js @@ -642,6 +642,11 @@ that.el.trigger('contentChanged', [that.id, response.contents, response]); that.render(response.contents, that.focus_on_submit_notification); that.updateProgress(response); + // This is used by the Learning MFE to know when the Entrance Exam has been passed + // for a user. The MFE is then able to respond appropriately. + if (response.entrance_exam_passed) { + window.parent.postMessage({type: 'entranceExam.passed'}, '*'); + } break; default: that.saveNotification.hide(); diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 54190fa833..0dcbcfdbe4 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -361,6 +361,7 @@ def get_course_tab_list(user, course): if tab.type != 'courseware': continue tab.name = _("Entrance Exam") + tab.title = _("Entrance Exam") # TODO: LEARNER-611 - once the course_info tab is removed, remove this code if not DISABLE_UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id) and tab.type == 'course_info': continue diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py index c5b82291dd..3a2d178dd6 100644 --- a/lms/djangoapps/courseware/views/index.py +++ b/lms/djangoapps/courseware/views/index.py @@ -29,7 +29,7 @@ from web_fragments.fragment import Fragment from common.djangoapps.edxmako.shortcuts import render_to_response, render_to_string from lms.djangoapps.courseware.exceptions import CourseAccessRedirect, Redirect from lms.djangoapps.experiments.utils import get_experiment_user_metadata_context -from lms.djangoapps.gating.api import get_entrance_exam_score_ratio, get_entrance_exam_usage_key +from lms.djangoapps.gating.api import get_entrance_exam_score, get_entrance_exam_usage_key from lms.djangoapps.grades.api import CourseGradeFactory from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.crawlers.models import CrawlersConfig @@ -531,7 +531,7 @@ class CoursewareIndex(View): """ if course_has_entrance_exam(self.course) and getattr(self.chapter, 'is_entrance_exam', False): courseware_context['entrance_exam_passed'] = user_has_passed_entrance_exam(self.effective_user, self.course) - courseware_context['entrance_exam_current_score'] = get_entrance_exam_score_ratio( + courseware_context['entrance_exam_current_score'] = get_entrance_exam_score( CourseGradeFactory().read(self.effective_user, self.course), get_entrance_exam_usage_key(self.course), ) diff --git a/lms/djangoapps/gating/api.py b/lms/djangoapps/gating/api.py index d68d9740b5..75715124cd 100644 --- a/lms/djangoapps/gating/api.py +++ b/lms/djangoapps/gating/api.py @@ -52,8 +52,8 @@ def evaluate_entrance_exam(course_grade, user): if ENTRANCE_EXAMS.is_enabled() and getattr(course, 'entrance_exam_enabled', False): if get_entrance_exam_content(user, course): exam_chapter_key = get_entrance_exam_usage_key(course) - exam_score_ratio = get_entrance_exam_score_ratio(course_grade, exam_chapter_key) - if exam_score_ratio >= course.entrance_exam_minimum_score_pct: + exam_score = get_entrance_exam_score(course_grade, exam_chapter_key) + if exam_score >= course.entrance_exam_minimum_score_pct: relationship_types = milestones_helpers.get_milestone_relationship_types() content_milestones = milestones_helpers.get_course_content_milestones( course.id, @@ -69,18 +69,18 @@ def get_entrance_exam_usage_key(course): """ Returns the UsageKey of the entrance exam for the course. """ - return UsageKey.from_string(course.entrance_exam_id).replace(course_key=course.id) + return course.entrance_exam_id and UsageKey.from_string(course.entrance_exam_id).replace(course_key=course.id) -def get_entrance_exam_score_ratio(course_grade, exam_chapter_key): +def get_entrance_exam_score(course_grade, exam_chapter_key): """ Returns the score for the given chapter as a ratio of the aggregated earned over the possible points, resulting in a decimal value less than 1. """ try: - entrance_exam_score_ratio = course_grade.chapter_percentage(exam_chapter_key) + entrance_exam_score = course_grade.chapter_percentage(exam_chapter_key) except KeyError: - entrance_exam_score_ratio = 0.0, 0.0 + entrance_exam_score = 0.0 log.warning('Gating: Unexpectedly failed to find chapter_grade for %s.', exam_chapter_key) - return entrance_exam_score_ratio + return entrance_exam_score diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0026_courseoverview_entrance_exam.py b/openedx/core/djangoapps/content/course_overviews/migrations/0026_courseoverview_entrance_exam.py new file mode 100644 index 0000000000..1815bb7c45 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/migrations/0026_courseoverview_entrance_exam.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.12 on 2022-02-25 20:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_overviews', '0025_auto_20210702_1602'), + ] + + operations = [ + migrations.AddField( + model_name='courseoverview', + name='entrance_exam_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='courseoverview', + name='entrance_exam_id', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='courseoverview', + name='entrance_exam_minimum_score_pct', + field=models.FloatField(default=0.65), + ), + migrations.AddField( + model_name='historicalcourseoverview', + name='entrance_exam_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='historicalcourseoverview', + name='entrance_exam_id', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='historicalcourseoverview', + name='entrance_exam_minimum_score_pct', + field=models.FloatField(default=0.65), + ), + ] diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py index 09ad1eec10..0ae4c0701a 100644 --- a/openedx/core/djangoapps/content/course_overviews/models.py +++ b/openedx/core/djangoapps/content/course_overviews/models.py @@ -12,9 +12,6 @@ from config_models.models import ConfigurationModel from django.conf import settings from django.db import models, transaction from django.db.models import Q -from django.db.models.fields import ( - BooleanField, DateTimeField, DecimalField, FloatField, IntegerField, TextField -) from django.db.models.signals import post_save, post_delete from django.db.utils import IntegrityError from django.template import defaultfilters @@ -65,82 +62,87 @@ class CourseOverview(TimeStampedModel): app_label = 'course_overviews' # IMPORTANT: Bump this whenever you modify this model and/or add a migration. - VERSION = 16 + VERSION = 17 # Cache entry versioning. - version = IntegerField() + version = models.IntegerField() # Course identification id = CourseKeyField(db_index=True, primary_key=True, max_length=255) _location = UsageKeyField(max_length=255) - org = TextField(max_length=255, default='outdated_entry') - display_name = TextField(null=True) - display_number_with_default = TextField() - display_org_with_default = TextField() + org = models.TextField(max_length=255, default='outdated_entry') + display_name = models.TextField(null=True) + display_number_with_default = models.TextField() + display_org_with_default = models.TextField() - start = DateTimeField(null=True) - end = DateTimeField(null=True) + start = models.DateTimeField(null=True) + end = models.DateTimeField(null=True) # These are deprecated and unused, but cannot be dropped via simple migration due to the size of the downstream # history table. See DENG-19 for details. # Please use start and end above for these values. - start_date = DateTimeField(null=True) - end_date = DateTimeField(null=True) + start_date = models.DateTimeField(null=True) + end_date = models.DateTimeField(null=True) - advertised_start = TextField(null=True) - announcement = DateTimeField(null=True) + advertised_start = models.TextField(null=True) + announcement = models.DateTimeField(null=True) # URLs # Not allowing null per django convention; not sure why many TextFields in this model do allow null - banner_image_url = TextField() - course_image_url = TextField() - social_sharing_url = TextField(null=True) - end_of_course_survey_url = TextField(null=True) + banner_image_url = models.TextField() + course_image_url = models.TextField() + social_sharing_url = models.TextField(null=True) + end_of_course_survey_url = models.TextField(null=True) # Certification data - certificates_display_behavior = TextField(null=True) - certificates_show_before_end = BooleanField(default=False) - cert_html_view_enabled = BooleanField(default=False) - has_any_active_web_certificate = BooleanField(default=False) - cert_name_short = TextField() - cert_name_long = TextField() - certificate_available_date = DateTimeField(default=None, null=True) + certificates_display_behavior = models.TextField(null=True) + certificates_show_before_end = models.BooleanField(default=False) + cert_html_view_enabled = models.BooleanField(default=False) + has_any_active_web_certificate = models.BooleanField(default=False) + cert_name_short = models.TextField() + cert_name_long = models.TextField() + certificate_available_date = models.DateTimeField(default=None, null=True) # Grading - lowest_passing_grade = DecimalField(max_digits=5, decimal_places=2, null=True) + lowest_passing_grade = models.DecimalField(max_digits=5, decimal_places=2, null=True) # Access parameters - days_early_for_beta = FloatField(null=True) - mobile_available = BooleanField(default=False) - visible_to_staff_only = BooleanField(default=False) - _pre_requisite_courses_json = TextField() # JSON representation of list of CourseKey strings + days_early_for_beta = models.FloatField(null=True) + mobile_available = models.BooleanField(default=False) + visible_to_staff_only = models.BooleanField(default=False) + _pre_requisite_courses_json = models.TextField() # JSON representation of list of CourseKey strings # Enrollment details - enrollment_start = DateTimeField(null=True) - enrollment_end = DateTimeField(null=True) - enrollment_domain = TextField(null=True) - invitation_only = BooleanField(default=False) - max_student_enrollments_allowed = IntegerField(null=True) + enrollment_start = models.DateTimeField(null=True) + enrollment_end = models.DateTimeField(null=True) + enrollment_domain = models.TextField(null=True) + invitation_only = models.BooleanField(default=False) + max_student_enrollments_allowed = models.IntegerField(null=True) # Catalog information - catalog_visibility = TextField(null=True) - short_description = TextField(null=True) - course_video_url = TextField(null=True) - effort = TextField(null=True) - self_paced = BooleanField(default=False) - marketing_url = TextField(null=True) - eligible_for_financial_aid = BooleanField(default=True) + catalog_visibility = models.TextField(null=True) + short_description = models.TextField(null=True) + course_video_url = models.TextField(null=True) + effort = models.TextField(null=True) + self_paced = models.BooleanField(default=False) + marketing_url = models.TextField(null=True) + eligible_for_financial_aid = models.BooleanField(default=True) # Course highlight info, used to guide course update emails - has_highlights = BooleanField(null=True, default=None) # if None, you have to look up the answer yourself + has_highlights = models.BooleanField(null=True, default=None) # if None, you have to look up the answer yourself # Proctoring - enable_proctored_exams = BooleanField(default=False) - proctoring_provider = TextField(null=True) - proctoring_escalation_email = TextField(null=True) - allow_proctoring_opt_out = BooleanField(default=False) + enable_proctored_exams = models.BooleanField(default=False) + proctoring_provider = models.TextField(null=True) + proctoring_escalation_email = models.TextField(null=True) + allow_proctoring_opt_out = models.BooleanField(default=False) - language = TextField(null=True) + # Entrance Exam information + entrance_exam_enabled = models.BooleanField(default=False) + entrance_exam_id = models.CharField(max_length=255, blank=True) + entrance_exam_minimum_score_pct = models.FloatField(default=0.65) + + language = models.TextField(null=True) history = HistoricalRecords() @@ -252,6 +254,16 @@ class CourseOverview(TimeStampedModel): course_overview.proctoring_escalation_email = course.proctoring_escalation_email course_overview.allow_proctoring_opt_out = course.allow_proctoring_opt_out + course_overview.entrance_exam_enabled = course.entrance_exam_enabled + # entrance_exam_id defaults to None in the course object, but '' is more reasonable for a string field + course_overview.entrance_exam_id = course.entrance_exam_id or '' + # Despite it being a float, the course object defaults to an int. So we will detect that case and update + # it to be a float like everything else. + if isinstance(course.entrance_exam_minimum_score_pct, int): + course_overview.entrance_exam_minimum_score_pct = course.entrance_exam_minimum_score_pct / 100 + else: + course_overview.entrance_exam_minimum_score_pct = course.entrance_exam_minimum_score_pct + if not CatalogIntegration.is_enabled(): course_overview.language = course.language diff --git a/openedx/core/djangoapps/courseware_api/serializers.py b/openedx/core/djangoapps/courseware_api/serializers.py index 2bb4b323df..1b86a84bd5 100644 --- a/openedx/core/djangoapps/courseware_api/serializers.py +++ b/openedx/core/djangoapps/courseware_api/serializers.py @@ -88,6 +88,7 @@ class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract- enrollment = serializers.DictField() enrollment_start = serializers.DateTimeField() enrollment_end = serializers.DateTimeField() + entrance_exam_data = serializers.DictField() id = serializers.CharField() # pylint: disable=invalid-name license = serializers.CharField() media = _CourseApiMediaCollectionSerializer(source='*') diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index e01ebca67c..f4bc772cae 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -4,6 +4,7 @@ Course API Views from completion.exceptions import UnavailableCompletionData from completion.utilities import get_key_to_last_completed_block from django.conf import settings +from django.utils.functional import cached_property from edx_django_utils.cache import TieredCache from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -32,6 +33,7 @@ from lms.djangoapps.course_goals.api import get_course_goal from lms.djangoapps.courseware.access import has_access 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 from lms.djangoapps.courseware.masquerade import ( is_masquerading_as_specific_student, setup_masquerade, @@ -44,6 +46,7 @@ from lms.djangoapps.courseware.toggles import ( course_exit_page_is_active, ) 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.verify_student.services import IDVerificationService from openedx.core.djangoapps.agreements.api import get_integrity_signature @@ -182,11 +185,20 @@ class CoursewareMeta: } return course_goals + @cached_property + def course_grade(self): + """ + Returns the Course Grade for the effective user in the course + + Cached property since we use this twice in the class and don't want to recreate the entire grade. + """ + return CourseGradeFactory().read(self.effective_user, self.course) + @property def user_has_passing_grade(self): """ Returns a boolean on if the effective_user has a passing grade in the course """ if not self.effective_user.is_anonymous: - user_grade = CourseGradeFactory().read(self.effective_user, self.course).percent + user_grade = self.course_grade.percent return user_grade >= self.course.lowest_passing_grade return False @@ -204,6 +216,24 @@ class CoursewareMeta: if self.enrollment_object: return get_cert_data(self.effective_user, self.course, self.enrollment_object.mode) + @property + def entrance_exam_data(self): + """ + Returns Entrance Exam data for the course + + Although some of the fields will have values (i.e. entrance_exam_minimum_score_pct and + entrance_exam_passed), nothing will be used unless entrance_exam_enabled is True. + """ + return { + 'entrance_exam_current_score': get_entrance_exam_score( + self.course_grade, get_entrance_exam_usage_key(self.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), + } + @property def verify_identity_url(self): """ @@ -383,6 +413,13 @@ class CoursewareInformation(RetrieveAPIView): * is_active: boolean * enrollment_end: Date enrollment ends, in ISO 8601 notation * enrollment_start: Date enrollment begins, in ISO 8601 notation + * entrance_exam_data: An object containing information about the course's entrance exam + * entrance_exam_current_score: (float) The users current score on the entrance exam + * entrance_exam_enabled: (bool) If the course has an entrance exam + * entrance_exam_id: (str) The block id for the entrance exam if enabled. Will be a section (chapter) + * entrance_exam_minimum_score_pct: (float) The minimum score a user must receive on the entrance exam + to unlock the remainder of the course. Returned as a float (i.e. 0.7 for 70%) + * entrance_exam_passed: (bool) Indicates if the entrance exam has been passed * id: A unique identifier of the course; a serialized representation of the opaque key identifying the course. * media: An object that contains named media items. Included here: