Skip to content

Commit

Permalink
feat: AA-1205: Add Learning MFE support for Entrance Exams
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
Dillon-Dumesnil committed Mar 4, 2022
1 parent 8178a03 commit e592c19
Show file tree
Hide file tree
Showing 8 changed files with 159 additions and 60 deletions.
5 changes: 5 additions & 0 deletions common/lib/xmodule/xmodule/js/src/capa/display.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion lms/djangoapps/courseware/tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,14 +361,15 @@ 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
if tab.type == 'static_tab' and tab.course_staff_only and \
not bool(user and has_access(user, 'staff', course, course.id)):
continue
# We had initially created a CourseTab.load() for dates that ended up
# persisting the dates tab tomodulestore on Course Run creation, but
# persisting the dates tab to modulestore on Course Run creation, but
# ignoring any static dates tab here we can fix forward without
# allowing the bug to continue to surface
if tab.type == 'dates':
Expand Down
4 changes: 2 additions & 2 deletions lms/djangoapps/courseware/views/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
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
Expand Down Expand Up @@ -531,7 +531,7 @@ def _add_entrance_exam_to_context(self, courseware_context):
"""
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),
)
Expand Down
14 changes: 7 additions & 7 deletions lms/djangoapps/gating/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -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),
),
]
110 changes: 61 additions & 49 deletions openedx/core/djangoapps/content/course_overviews/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,6 @@
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
Expand Down Expand Up @@ -65,82 +62,87 @@ class Meta:
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()

Expand Down Expand Up @@ -252,6 +254,16 @@ def _create_or_update(cls, course): # lint-amnesty, pylint: disable=too-many-st
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

Expand Down
1 change: 1 addition & 0 deletions openedx/core/djangoapps/courseware_api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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='*')
Expand Down
39 changes: 38 additions & 1 deletion openedx/core/djangoapps/courseware_api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from completion.utilities import get_key_to_last_completed_block
from django.conf import settings
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext as _
from edx_django_utils.cache import TieredCache
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
Expand Down Expand Up @@ -34,6 +35,7 @@
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,
Expand All @@ -47,6 +49,7 @@
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
Expand Down Expand Up @@ -202,11 +205,20 @@ def course_goals(self):
}
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

Expand All @@ -224,6 +236,24 @@ def certificate_data(self):
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):
"""
Expand Down Expand Up @@ -403,6 +433,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:
Expand Down

0 comments on commit e592c19

Please sign in to comment.