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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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='*')
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user