Skip to content

Commit d43ece5

Browse files
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.
1 parent 071da2d commit d43ece5

File tree

8 files changed

+158
-59
lines changed

8 files changed

+158
-59
lines changed

common/lib/xmodule/xmodule/js/src/capa/display.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,11 @@
642642
that.el.trigger('contentChanged', [that.id, response.contents, response]);
643643
that.render(response.contents, that.focus_on_submit_notification);
644644
that.updateProgress(response);
645+
// This is used by the Learning MFE to know when the Entrance Exam has been passed
646+
// for a user. The MFE is then able to respond appropriately.
647+
if (response.entrance_exam_passed) {
648+
window.parent.postMessage({type: 'entranceExam.passed'}, '*');
649+
}
645650
break;
646651
default:
647652
that.saveNotification.hide();

lms/djangoapps/courseware/tabs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ def get_course_tab_list(user, course):
361361
if tab.type != 'courseware':
362362
continue
363363
tab.name = _("Entrance Exam")
364+
tab.title = _("Entrance Exam")
364365
# TODO: LEARNER-611 - once the course_info tab is removed, remove this code
365366
if not DISABLE_UNIFIED_COURSE_TAB_FLAG.is_enabled(course.id) and tab.type == 'course_info':
366367
continue

lms/djangoapps/courseware/views/index.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from common.djangoapps.edxmako.shortcuts import render_to_response, render_to_string
3030
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect, Redirect
3131
from lms.djangoapps.experiments.utils import get_experiment_user_metadata_context
32-
from lms.djangoapps.gating.api import get_entrance_exam_score_ratio, get_entrance_exam_usage_key
32+
from lms.djangoapps.gating.api import get_entrance_exam_score, get_entrance_exam_usage_key
3333
from lms.djangoapps.grades.api import CourseGradeFactory
3434
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
3535
from openedx.core.djangoapps.crawlers.models import CrawlersConfig
@@ -531,7 +531,7 @@ def _add_entrance_exam_to_context(self, courseware_context):
531531
"""
532532
if course_has_entrance_exam(self.course) and getattr(self.chapter, 'is_entrance_exam', False):
533533
courseware_context['entrance_exam_passed'] = user_has_passed_entrance_exam(self.effective_user, self.course)
534-
courseware_context['entrance_exam_current_score'] = get_entrance_exam_score_ratio(
534+
courseware_context['entrance_exam_current_score'] = get_entrance_exam_score(
535535
CourseGradeFactory().read(self.effective_user, self.course),
536536
get_entrance_exam_usage_key(self.course),
537537
)

lms/djangoapps/gating/api.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ def evaluate_entrance_exam(course_grade, user):
5252
if ENTRANCE_EXAMS.is_enabled() and getattr(course, 'entrance_exam_enabled', False):
5353
if get_entrance_exam_content(user, course):
5454
exam_chapter_key = get_entrance_exam_usage_key(course)
55-
exam_score_ratio = get_entrance_exam_score_ratio(course_grade, exam_chapter_key)
56-
if exam_score_ratio >= course.entrance_exam_minimum_score_pct:
55+
exam_score = get_entrance_exam_score(course_grade, exam_chapter_key)
56+
if exam_score >= course.entrance_exam_minimum_score_pct:
5757
relationship_types = milestones_helpers.get_milestone_relationship_types()
5858
content_milestones = milestones_helpers.get_course_content_milestones(
5959
course.id,
@@ -69,18 +69,18 @@ def get_entrance_exam_usage_key(course):
6969
"""
7070
Returns the UsageKey of the entrance exam for the course.
7171
"""
72-
return UsageKey.from_string(course.entrance_exam_id).replace(course_key=course.id)
72+
return course.entrance_exam_id and UsageKey.from_string(course.entrance_exam_id).replace(course_key=course.id)
7373

7474

75-
def get_entrance_exam_score_ratio(course_grade, exam_chapter_key):
75+
def get_entrance_exam_score(course_grade, exam_chapter_key):
7676
"""
7777
Returns the score for the given chapter as a ratio of the
7878
aggregated earned over the possible points, resulting in a
7979
decimal value less than 1.
8080
"""
8181
try:
82-
entrance_exam_score_ratio = course_grade.chapter_percentage(exam_chapter_key)
82+
entrance_exam_score = course_grade.chapter_percentage(exam_chapter_key)
8383
except KeyError:
84-
entrance_exam_score_ratio = 0.0, 0.0
84+
entrance_exam_score = 0.0
8585
log.warning('Gating: Unexpectedly failed to find chapter_grade for %s.', exam_chapter_key)
86-
return entrance_exam_score_ratio
86+
return entrance_exam_score
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 3.2.12 on 2022-02-25 20:05
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('course_overviews', '0025_auto_20210702_1602'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='courseoverview',
15+
name='entrance_exam_enabled',
16+
field=models.BooleanField(default=False),
17+
),
18+
migrations.AddField(
19+
model_name='courseoverview',
20+
name='entrance_exam_id',
21+
field=models.CharField(blank=True, max_length=255),
22+
),
23+
migrations.AddField(
24+
model_name='courseoverview',
25+
name='entrance_exam_minimum_score_pct',
26+
field=models.FloatField(default=0.65),
27+
),
28+
migrations.AddField(
29+
model_name='historicalcourseoverview',
30+
name='entrance_exam_enabled',
31+
field=models.BooleanField(default=False),
32+
),
33+
migrations.AddField(
34+
model_name='historicalcourseoverview',
35+
name='entrance_exam_id',
36+
field=models.CharField(blank=True, max_length=255),
37+
),
38+
migrations.AddField(
39+
model_name='historicalcourseoverview',
40+
name='entrance_exam_minimum_score_pct',
41+
field=models.FloatField(default=0.65),
42+
),
43+
]

openedx/core/djangoapps/content/course_overviews/models.py

Lines changed: 61 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@
1212
from django.conf import settings
1313
from django.db import models, transaction
1414
from django.db.models import Q
15-
from django.db.models.fields import (
16-
BooleanField, DateTimeField, DecimalField, FloatField, IntegerField, TextField
17-
)
1815
from django.db.models.signals import post_save, post_delete
1916
from django.db.utils import IntegrityError
2017
from django.template import defaultfilters
@@ -65,82 +62,87 @@ class Meta:
6562
app_label = 'course_overviews'
6663

6764
# IMPORTANT: Bump this whenever you modify this model and/or add a migration.
68-
VERSION = 16
65+
VERSION = 17
6966

7067
# Cache entry versioning.
71-
version = IntegerField()
68+
version = models.IntegerField()
7269

7370
# Course identification
7471
id = CourseKeyField(db_index=True, primary_key=True, max_length=255)
7572
_location = UsageKeyField(max_length=255)
76-
org = TextField(max_length=255, default='outdated_entry')
77-
display_name = TextField(null=True)
78-
display_number_with_default = TextField()
79-
display_org_with_default = TextField()
73+
org = models.TextField(max_length=255, default='outdated_entry')
74+
display_name = models.TextField(null=True)
75+
display_number_with_default = models.TextField()
76+
display_org_with_default = models.TextField()
8077

81-
start = DateTimeField(null=True)
82-
end = DateTimeField(null=True)
78+
start = models.DateTimeField(null=True)
79+
end = models.DateTimeField(null=True)
8380

8481
# These are deprecated and unused, but cannot be dropped via simple migration due to the size of the downstream
8582
# history table. See DENG-19 for details.
8683
# Please use start and end above for these values.
87-
start_date = DateTimeField(null=True)
88-
end_date = DateTimeField(null=True)
84+
start_date = models.DateTimeField(null=True)
85+
end_date = models.DateTimeField(null=True)
8986

90-
advertised_start = TextField(null=True)
91-
announcement = DateTimeField(null=True)
87+
advertised_start = models.TextField(null=True)
88+
announcement = models.DateTimeField(null=True)
9289

9390
# URLs
9491
# Not allowing null per django convention; not sure why many TextFields in this model do allow null
95-
banner_image_url = TextField()
96-
course_image_url = TextField()
97-
social_sharing_url = TextField(null=True)
98-
end_of_course_survey_url = TextField(null=True)
92+
banner_image_url = models.TextField()
93+
course_image_url = models.TextField()
94+
social_sharing_url = models.TextField(null=True)
95+
end_of_course_survey_url = models.TextField(null=True)
9996

10097
# Certification data
101-
certificates_display_behavior = TextField(null=True)
102-
certificates_show_before_end = BooleanField(default=False)
103-
cert_html_view_enabled = BooleanField(default=False)
104-
has_any_active_web_certificate = BooleanField(default=False)
105-
cert_name_short = TextField()
106-
cert_name_long = TextField()
107-
certificate_available_date = DateTimeField(default=None, null=True)
98+
certificates_display_behavior = models.TextField(null=True)
99+
certificates_show_before_end = models.BooleanField(default=False)
100+
cert_html_view_enabled = models.BooleanField(default=False)
101+
has_any_active_web_certificate = models.BooleanField(default=False)
102+
cert_name_short = models.TextField()
103+
cert_name_long = models.TextField()
104+
certificate_available_date = models.DateTimeField(default=None, null=True)
108105

109106
# Grading
110-
lowest_passing_grade = DecimalField(max_digits=5, decimal_places=2, null=True)
107+
lowest_passing_grade = models.DecimalField(max_digits=5, decimal_places=2, null=True)
111108

112109
# Access parameters
113-
days_early_for_beta = FloatField(null=True)
114-
mobile_available = BooleanField(default=False)
115-
visible_to_staff_only = BooleanField(default=False)
116-
_pre_requisite_courses_json = TextField() # JSON representation of list of CourseKey strings
110+
days_early_for_beta = models.FloatField(null=True)
111+
mobile_available = models.BooleanField(default=False)
112+
visible_to_staff_only = models.BooleanField(default=False)
113+
_pre_requisite_courses_json = models.TextField() # JSON representation of list of CourseKey strings
117114

118115
# Enrollment details
119-
enrollment_start = DateTimeField(null=True)
120-
enrollment_end = DateTimeField(null=True)
121-
enrollment_domain = TextField(null=True)
122-
invitation_only = BooleanField(default=False)
123-
max_student_enrollments_allowed = IntegerField(null=True)
116+
enrollment_start = models.DateTimeField(null=True)
117+
enrollment_end = models.DateTimeField(null=True)
118+
enrollment_domain = models.TextField(null=True)
119+
invitation_only = models.BooleanField(default=False)
120+
max_student_enrollments_allowed = models.IntegerField(null=True)
124121

125122
# Catalog information
126-
catalog_visibility = TextField(null=True)
127-
short_description = TextField(null=True)
128-
course_video_url = TextField(null=True)
129-
effort = TextField(null=True)
130-
self_paced = BooleanField(default=False)
131-
marketing_url = TextField(null=True)
132-
eligible_for_financial_aid = BooleanField(default=True)
123+
catalog_visibility = models.TextField(null=True)
124+
short_description = models.TextField(null=True)
125+
course_video_url = models.TextField(null=True)
126+
effort = models.TextField(null=True)
127+
self_paced = models.BooleanField(default=False)
128+
marketing_url = models.TextField(null=True)
129+
eligible_for_financial_aid = models.BooleanField(default=True)
133130

134131
# Course highlight info, used to guide course update emails
135-
has_highlights = BooleanField(null=True, default=None) # if None, you have to look up the answer yourself
132+
has_highlights = models.BooleanField(null=True, default=None) # if None, you have to look up the answer yourself
136133

137134
# Proctoring
138-
enable_proctored_exams = BooleanField(default=False)
139-
proctoring_provider = TextField(null=True)
140-
proctoring_escalation_email = TextField(null=True)
141-
allow_proctoring_opt_out = BooleanField(default=False)
135+
enable_proctored_exams = models.BooleanField(default=False)
136+
proctoring_provider = models.TextField(null=True)
137+
proctoring_escalation_email = models.TextField(null=True)
138+
allow_proctoring_opt_out = models.BooleanField(default=False)
142139

143-
language = TextField(null=True)
140+
# Entrance Exam information
141+
entrance_exam_enabled = models.BooleanField(default=False)
142+
entrance_exam_id = models.CharField(max_length=255, blank=True)
143+
entrance_exam_minimum_score_pct = models.FloatField(default=0.65)
144+
145+
language = models.TextField(null=True)
144146

145147
history = HistoricalRecords()
146148

@@ -252,6 +254,16 @@ def _create_or_update(cls, course): # lint-amnesty, pylint: disable=too-many-st
252254
course_overview.proctoring_escalation_email = course.proctoring_escalation_email
253255
course_overview.allow_proctoring_opt_out = course.allow_proctoring_opt_out
254256

257+
course_overview.entrance_exam_enabled = course.entrance_exam_enabled
258+
# entrance_exam_id defaults to None in the course object, but '' is more reasonable for a string field
259+
course_overview.entrance_exam_id = course.entrance_exam_id or ''
260+
# Despite it being a float, the course object defaults to an int. So we will detect that case and update
261+
# it to be a float like everything else.
262+
if isinstance(course.entrance_exam_minimum_score_pct, int):
263+
course_overview.entrance_exam_minimum_score_pct = course.entrance_exam_minimum_score_pct / 100
264+
else:
265+
course_overview.entrance_exam_minimum_score_pct = course.entrance_exam_minimum_score_pct
266+
255267
if not CatalogIntegration.is_enabled():
256268
course_overview.language = course.language
257269

openedx/core/djangoapps/courseware_api/serializers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract-
8888
enrollment = serializers.DictField()
8989
enrollment_start = serializers.DateTimeField()
9090
enrollment_end = serializers.DateTimeField()
91+
entrance_exam_data = serializers.DictField()
9192
id = serializers.CharField() # pylint: disable=invalid-name
9293
license = serializers.CharField()
9394
media = _CourseApiMediaCollectionSerializer(source='*')

openedx/core/djangoapps/courseware_api/views.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from completion.exceptions import UnavailableCompletionData
55
from completion.utilities import get_key_to_last_completed_block
66
from django.conf import settings
7+
from django.utils.functional import cached_property
78
from edx_django_utils.cache import TieredCache
89
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
910
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
@@ -32,6 +33,7 @@
3233
from lms.djangoapps.courseware.access import has_access
3334

3435
from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs
36+
from lms.djangoapps.courseware.entrance_exams import course_has_entrance_exam, user_has_passed_entrance_exam
3537
from lms.djangoapps.courseware.masquerade import (
3638
is_masquerading_as_specific_student,
3739
setup_masquerade,
@@ -44,6 +46,7 @@
4446
course_exit_page_is_active,
4547
)
4648
from lms.djangoapps.courseware.views.views import get_cert_data
49+
from lms.djangoapps.gating.api import get_entrance_exam_score, get_entrance_exam_usage_key
4750
from lms.djangoapps.grades.api import CourseGradeFactory
4851
from lms.djangoapps.verify_student.services import IDVerificationService
4952
from openedx.core.djangoapps.agreements.api import get_integrity_signature
@@ -182,11 +185,20 @@ def course_goals(self):
182185
}
183186
return course_goals
184187

188+
@cached_property
189+
def course_grade(self):
190+
"""
191+
Returns the Course Grade for the effective user in the course
192+
193+
Cached property since we use this twice in the class and don't want to recreate the entire grade.
194+
"""
195+
return CourseGradeFactory().read(self.effective_user, self.course)
196+
185197
@property
186198
def user_has_passing_grade(self):
187199
""" Returns a boolean on if the effective_user has a passing grade in the course """
188200
if not self.effective_user.is_anonymous:
189-
user_grade = CourseGradeFactory().read(self.effective_user, self.course).percent
201+
user_grade = self.course_grade.percent
190202
return user_grade >= self.course.lowest_passing_grade
191203
return False
192204

@@ -204,6 +216,24 @@ def certificate_data(self):
204216
if self.enrollment_object:
205217
return get_cert_data(self.effective_user, self.course, self.enrollment_object.mode)
206218

219+
@property
220+
def entrance_exam_data(self):
221+
"""
222+
Returns Entrance Exam data for the course
223+
224+
Although some of the fields will have values (i.e. entrance_exam_minimum_score_pct and
225+
entrance_exam_passed), nothing will be used unless entrance_exam_enabled is True.
226+
"""
227+
return {
228+
'entrance_exam_current_score': get_entrance_exam_score(
229+
self.course_grade, get_entrance_exam_usage_key(self.overview),
230+
),
231+
'entrance_exam_enabled': course_has_entrance_exam(self.overview),
232+
'entrance_exam_id': self.overview.entrance_exam_id,
233+
'entrance_exam_minimum_score_pct': self.overview.entrance_exam_minimum_score_pct,
234+
'entrance_exam_passed': user_has_passed_entrance_exam(self.effective_user, self.overview),
235+
}
236+
207237
@property
208238
def verify_identity_url(self):
209239
"""
@@ -383,6 +413,13 @@ class CoursewareInformation(RetrieveAPIView):
383413
* is_active: boolean
384414
* enrollment_end: Date enrollment ends, in ISO 8601 notation
385415
* enrollment_start: Date enrollment begins, in ISO 8601 notation
416+
* entrance_exam_data: An object containing information about the course's entrance exam
417+
* entrance_exam_current_score: (float) The users current score on the entrance exam
418+
* entrance_exam_enabled: (bool) If the course has an entrance exam
419+
* entrance_exam_id: (str) The block id for the entrance exam if enabled. Will be a section (chapter)
420+
* entrance_exam_minimum_score_pct: (float) The minimum score a user must receive on the entrance exam
421+
to unlock the remainder of the course. Returned as a float (i.e. 0.7 for 70%)
422+
* entrance_exam_passed: (bool) Indicates if the entrance exam has been passed
386423
* id: A unique identifier of the course; a serialized representation
387424
of the opaque key identifying the course.
388425
* media: An object that contains named media items. Included here:

0 commit comments

Comments
 (0)