feat: AA-1205: Add Learning MFE support for Entrance Exams

* Adds entrance exam information to the Course Overview object
    * Enables hiding other tabs since the get_course_tab_list uses
      a Course Overview
    * Enables using the entrance exam helper functions to determine
      if Entrance exams are being used in this course.
* Posts a message when Entrance Exam is passed to parent container for
usage in the Learning MFE
* Overrides the 'title' field of the courseware tab since the Learning MFE
uses that over the 'name' field.
This commit is contained in:
Dillon Dumesnil
2022-03-02 14:24:56 -05:00
parent 071da2da04
commit d43ece5dba
8 changed files with 158 additions and 59 deletions

View File

@@ -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();

View File

@@ -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

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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),
),
]

View File

@@ -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

View File

@@ -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='*')

View File

@@ -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: