From eb346f1ab24eb03132a60530f95fcc27c99e5fe3 Mon Sep 17 00:00:00 2001 From: "Albert (AJ) St. Aubin" Date: Fri, 4 Jun 2021 11:44:30 -0400 Subject: [PATCH] feature: Updated the text when a learner earns a cert on the Dashboard [MICROBA-678] This patch will update the text on the course dashboard when a learner successfully earns a certificate and that certificate is available. It also adds to the Outline API for the Outline in the Learning MFE so that the same changes can be made there. --- .../student/tests/test_certificates.py | 2 +- common/djangoapps/student/tests/test_views.py | 114 ++++++++++-------- common/djangoapps/util/course.py | 14 --- .../course_home_api/outline/v1/serializers.py | 2 + .../course_home_api/outline/v1/views.py | 4 + lms/static/sass/multicourse/_dashboard.scss | 9 +- .../_dashboard_certificate_information.html | 26 ++-- 7 files changed, 85 insertions(+), 86 deletions(-) diff --git a/common/djangoapps/student/tests/test_certificates.py b/common/djangoapps/student/tests/test_certificates.py index ba71a96a85..68935bd93d 100644 --- a/common/djangoapps/student/tests/test_certificates.py +++ b/common/djangoapps/student/tests/test_certificates.py @@ -275,5 +275,5 @@ class CertificateDisplayTestLinkedHtmlView(CertificateDisplayTestBase): response = self.client.get(reverse('dashboard')) - self.assertContains(response, 'View Test_Certificate') + self.assertContains(response, 'View my Test_Certificate') self.assertContains(response, test_url) diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index ffd0cdaf2a..3ee0dbd7c7 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -33,6 +33,7 @@ from common.djangoapps.util.milestones_helpers import ( ) from common.djangoapps.util.testing import UrlResetMixin # lint-amnesty, pylint: disable=unused-import from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory +from lms.djangoapps.certificates.data import CertificateStatuses from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory @@ -44,6 +45,10 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory PASSWORD = 'test' +TOMORROW = now() + timedelta(days=1) +ONE_WEEK_AGO = now() - timedelta(weeks=1) +THREE_YEARS_FROM_NOW = now() + timedelta(days=(365 * 3)) +THREE_YEARS_AGO = now() - timedelta(days=(365 * 3)) @ddt.ddt @@ -167,9 +172,6 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, EMAIL_SETTINGS_ELEMENT_ID = "#actions-item-email-settings-0" ENABLED_SIGNALS = ['course_published'] - TOMORROW = now() + timedelta(days=1) - THREE_YEARS_FROM_NOW = now() + timedelta(days=(365 * 3)) - THREE_YEARS_AGO = now() - timedelta(days=(365 * 3)) MOCK_SETTINGS = { 'FEATURES': { 'DISABLE_START_DATES': False, @@ -219,38 +221,50 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, response = self.client.get(self.path) self.assertRedirects(response, reverse('account_settings')) - def test_grade_appears_before_course_end_date(self): - """ - Verify that learners are not able to see their final grade before the end - of course in the learner dashboard - """ - self.course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') # lint-amnesty, pylint: disable=attribute-defined-outside-init - self.course = CourseOverviewFactory.create(id=self.course_key, end_date=self.TOMORROW, # lint-amnesty, pylint: disable=attribute-defined-outside-init - certificate_available_date=self.THREE_YEARS_AGO, - lowest_passing_grade=0.3) - self.course_enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user) # lint-amnesty, pylint: disable=attribute-defined-outside-init - GeneratedCertificateFactory(status='notpassing', course_id=self.course.id, user=self.user, grade=0.45) - + def test_course_cert_available_message_after_course_end(self): + course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') + course = CourseOverviewFactory.create( + id=course_key, + end_date=THREE_YEARS_AGO, + certificate_available_date=TOMORROW, + lowest_passing_grade=0.3 + ) + CourseEnrollmentFactory(course_id=course.id, user=self.user) + GeneratedCertificateFactory( + status=CertificateStatuses.downloadable, course_id=course.id, user=self.user, grade=0.45 + ) response = self.client.get(reverse('dashboard')) - # The final grade does not appear before the course has ended - self.assertContains(response, 'Your final grade:') - self.assertContains(response, '45%') - - def test_grade_not_appears_before_cert_available_date(self): - """ - Verify that learners are able to see their final grade of the course in - the learner dashboard after the course had ended - """ - self.course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') # lint-amnesty, pylint: disable=attribute-defined-outside-init - self.course = CourseOverviewFactory.create(id=self.course_key, end_date=self.THREE_YEARS_AGO, # lint-amnesty, pylint: disable=attribute-defined-outside-init - certificate_available_date=self.TOMORROW, - lowest_passing_grade=0.3) - self.course_enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user) # lint-amnesty, pylint: disable=attribute-defined-outside-init - GeneratedCertificateFactory(status='notpassing', course_id=self.course.id, user=self.user, grade=0.45) + self.assertContains(response, 'Your grade and certificate will be ready after') + def test_course_cert_available_message_same_day_as_course_end(self): + course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') + course = CourseOverviewFactory.create( + id=course_key, + end_date=TOMORROW, + certificate_available_date=TOMORROW, + lowest_passing_grade=0.3 + ) + CourseEnrollmentFactory(course_id=course.id, user=self.user) + GeneratedCertificateFactory( + status=CertificateStatuses.downloadable, course_id=course.id, user=self.user, grade=0.45 + ) response = self.client.get(reverse('dashboard')) - self.assertNotContains(response, 'Your final grade:') - self.assertNotContains(response, '45%') + self.assertContains(response, 'Your grade and certificate will be ready after') + + def test_cert_available_message_after_course_end(self): + course_key = CourseKey.from_string('course-v1:edX+DemoX+Demo_Course') + course = CourseOverviewFactory.create( + id=course_key, + end_date=ONE_WEEK_AGO, + certificate_available_date=now(), + lowest_passing_grade=0.3 + ) + CourseEnrollmentFactory(course_id=course.id, user=self.user) + GeneratedCertificateFactory( + status=CertificateStatuses.downloadable, course_id=course.id, user=self.user, grade=0.45 + ) + response = self.client.get(reverse('dashboard')) + self.assertContains(response, 'Congratulations! Your certificate is ready.') @patch.multiple('django.conf.settings', **MOCK_SETTINGS) @ddt.data( @@ -266,7 +280,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, Verify that the course sharing icons show up if course is starting in future and any of marketing or social sharing urls are set. """ - self.course = CourseFactory.create(start=self.TOMORROW, emit_signals=True, default_store=modulestore_type) # lint-amnesty, pylint: disable=attribute-defined-outside-init + self.course = CourseFactory.create(start=TOMORROW, emit_signals=True, default_store=modulestore_type) # lint-amnesty, pylint: disable=attribute-defined-outside-init self.course_enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user) # lint-amnesty, pylint: disable=attribute-defined-outside-init self.set_course_sharing_urls(set_marketing, set_social_sharing) @@ -316,11 +330,11 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, CourseEntitlementFactory.create(user=self.user, course_uuid=program['courses'][0]['uuid']) mock_get_programs.return_value = [program] course_key = CourseKey.from_string('course-v1:FAKE+FA1-MA1.X+3T2017') - mock_course_overview.return_value = CourseOverviewFactory.create(start=self.TOMORROW, id=course_key) + mock_course_overview.return_value = CourseOverviewFactory.create(start=TOMORROW, id=course_key) mock_course_runs.return_value = [ { 'key': str(course_key), - 'enrollment_end': str(self.TOMORROW), + 'enrollment_end': str(TOMORROW), 'pacing_type': 'instructor_paced', 'type': 'verified', 'status': 'published' @@ -347,7 +361,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, mock_course_runs.return_value = [ { 'key': 'course-v1:edX+toy+2012_Fall', - 'enrollment_end': str(self.TOMORROW), + 'enrollment_end': str(TOMORROW), 'pacing_type': 'instructor_paced', 'type': 'verified', 'status': 'published' @@ -370,14 +384,14 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, """ CourseEntitlementFactory( user=self.user, - created=self.THREE_YEARS_AGO, + created=THREE_YEARS_AGO, expired_at=now() ) - mock_course_overview.return_value = CourseOverviewFactory(start=self.TOMORROW) + mock_course_overview.return_value = CourseOverviewFactory(start=TOMORROW) mock_course_runs.return_value = [ { 'key': 'course-v1:FAKE+FA1-MA1.X+3T2017', - 'enrollment_end': str(self.TOMORROW), + 'enrollment_end': str(TOMORROW), 'pacing_type': 'instructor_paced', 'type': 'verified', 'status': 'published' @@ -401,7 +415,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, # Test an enrollment end in the past mocked_course_overview = CourseOverviewFactory.create( - start=self.TOMORROW, end=self.THREE_YEARS_FROM_NOW, self_paced=True, enrollment_end=self.THREE_YEARS_AGO + start=TOMORROW, end=THREE_YEARS_FROM_NOW, self_paced=True, enrollment_end=THREE_YEARS_AGO ) mock_course_overview.return_value = mocked_course_overview course_enrollment = CourseEnrollmentFactory(user=self.user, course_id=str(mocked_course_overview.id)) @@ -419,7 +433,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, # self.assertIn(noAvailableSessions, response.content) # Test an enrollment end in the future sets an availableSession - mocked_course_overview.enrollment_end = self.TOMORROW + mocked_course_overview.enrollment_end = TOMORROW mocked_course_overview.save() mock_course_overview.return_value = mocked_course_overview @@ -465,7 +479,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, - a related programs message """ mocked_course_overview = CourseOverviewFactory( - start=self.TOMORROW, self_paced=True, enrollment_end=self.TOMORROW + start=TOMORROW, self_paced=True, enrollment_end=TOMORROW ) mock_course_overview.return_value = mocked_course_overview course_enrollment = CourseEnrollmentFactory(user=self.user, course_id=str(mocked_course_overview.id)) @@ -500,10 +514,10 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, - a related programs message """ mocked_course_overview = CourseOverviewFactory( - start=self.TOMORROW, self_paced=True, enrollment_end=self.TOMORROW + start=TOMORROW, self_paced=True, enrollment_end=TOMORROW ) mock_course_overview.return_value = mocked_course_overview - course_enrollment = CourseEnrollmentFactory(user=self.user, course_id=str(mocked_course_overview.id), created=self.THREE_YEARS_AGO) # lint-amnesty, pylint: disable=line-too-long + course_enrollment = CourseEnrollmentFactory(user=self.user, course_id=str(mocked_course_overview.id), created=THREE_YEARS_AGO) # lint-amnesty, pylint: disable=line-too-long mock_course_runs.return_value = [ { 'key': str(mocked_course_overview.id), @@ -513,7 +527,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, 'status': 'published' } ] - entitlement = CourseEntitlementFactory(user=self.user, enrollment_course_run=course_enrollment, created=self.THREE_YEARS_AGO) # lint-amnesty, pylint: disable=line-too-long + entitlement = CourseEntitlementFactory(user=self.user, enrollment_course_run=course_enrollment, created=THREE_YEARS_AGO) # lint-amnesty, pylint: disable=line-too-long program = ProgramFactory() program['courses'][0]['course_runs'] = [{'key': str(mocked_course_overview.id)}] program['courses'][0]['uuid'] = entitlement.course_uuid @@ -531,7 +545,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, """ mock_email_feature.return_value = True course_overview = CourseOverviewFactory( - start=self.TOMORROW, self_paced=True, enrollment_end=self.TOMORROW + start=TOMORROW, self_paced=True, enrollment_end=TOMORROW ) course_enrollment = CourseEnrollmentFactory(user=self.user, course_id=course_overview.id) entitlement = CourseEntitlementFactory(user=self.user, enrollment_course_run=course_enrollment) @@ -551,7 +565,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, Assert that the Email Settings action is not shown when the entitlement is not fulfilled. """ mock_email_feature.return_value = True - mock_course_overview.return_value = CourseOverviewFactory(start=self.TOMORROW) + mock_course_overview.return_value = CourseOverviewFactory(start=TOMORROW) CourseEntitlementFactory(user=self.user) response = self.client.get(self.path) assert pq(response.content)(self.EMAIL_SETTINGS_ELEMENT_ID).length == 0 @@ -758,17 +772,17 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, Links will be removed from the course title, course image and button (View Course/Resume Course). The course card should have an access expired message. """ - CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=self.THREE_YEARS_AGO - timedelta(days=30)) + CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=THREE_YEARS_AGO - timedelta(days=30)) self.override_waffle_switch(True) - course = CourseFactory.create(start=self.THREE_YEARS_AGO) + course = CourseFactory.create(start=THREE_YEARS_AGO) add_course_mode(course, mode_slug=CourseMode.AUDIT) add_course_mode(course) enrollment = CourseEnrollmentFactory.create( user=self.user, course_id=course.id ) - enrollment.created = self.THREE_YEARS_AGO + timedelta(days=1) + enrollment.created = THREE_YEARS_AGO + timedelta(days=1) enrollment.save() response = self.client.get(reverse('dashboard')) diff --git a/common/djangoapps/util/course.py b/common/djangoapps/util/course.py index fe6721499a..1a894003e0 100644 --- a/common/djangoapps/util/course.py +++ b/common/djangoapps/util/course.py @@ -7,7 +7,6 @@ import logging from urllib.parse import urlencode from django.conf import settings -from django.utils.timezone import now from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -71,16 +70,3 @@ def has_certificates_enabled(course): if not settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False): return False return course.cert_html_view_enabled - - -def should_display_grade(course_overview): - """ - Returns True or False depending upon either certificate available date - or course end date - """ - cert_available_date = course_overview.certificate_available_date - current_date = now().replace(hour=0, minute=0, second=0, microsecond=0) - if cert_available_date: - return cert_available_date < current_date - - return course_overview.end and course_overview.end < current_date diff --git a/lms/djangoapps/course_home_api/outline/v1/serializers.py b/lms/djangoapps/course_home_api/outline/v1/serializers.py index 56991e2aee..1f16d83566 100644 --- a/lms/djangoapps/course_home_api/outline/v1/serializers.py +++ b/lms/djangoapps/course_home_api/outline/v1/serializers.py @@ -6,6 +6,7 @@ from django.utils.translation import ngettext from rest_framework import serializers from lms.djangoapps.course_home_api.dates.v1.serializers import DateSummarySerializer +from lms.djangoapps.course_home_api.progress.v1.serializers import CertificateDataSerializer from lms.djangoapps.course_home_api.mixins import DatesBannerSerializerMixin, VerifiedModeSerializerMixin @@ -113,6 +114,7 @@ class OutlineTabSerializer(DatesBannerSerializerMixin, VerifiedModeSerializerMix Serializer for the Outline Tab """ access_expiration = serializers.DictField() + cert_data = CertificateDataSerializer() course_blocks = CourseBlockSerializer() course_goals = CourseGoalsSerializer() course_tools = CourseToolSerializer(many=True) diff --git a/lms/djangoapps/course_home_api/outline/v1/views.py b/lms/djangoapps/course_home_api/outline/v1/views.py index 5f90f99b67..0b81d36af3 100644 --- a/lms/djangoapps/course_home_api/outline/v1/views.py +++ b/lms/djangoapps/course_home_api/outline/v1/views.py @@ -37,6 +37,7 @@ from lms.djangoapps.courseware.context_processor import user_timezone_locale_pre from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_info_section, get_course_with_access from lms.djangoapps.courseware.date_summary import TodaysDate from lms.djangoapps.courseware.masquerade import is_masquerading, setup_masquerade +from lms.djangoapps.courseware.views.views import get_cert_data from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.features.course_duration_limits.access import get_access_expiration_data @@ -196,6 +197,7 @@ class OutlineTabView(RetrieveAPIView): # Set all of the defaults access_expiration = None + cert_data = None course_blocks = None course_goals = { 'goal_options': [], @@ -232,6 +234,7 @@ class OutlineTabView(RetrieveAPIView): offer_data = generate_offer_data(request.user, course_overview) access_expiration = get_access_expiration_data(request.user, course_overview) + cert_data = get_cert_data(request.user, course, enrollment.mode) if is_enrolled else None # Only show the set course goal message for enrolled, unverified # users in a course that allows for verified statuses. @@ -277,6 +280,7 @@ class OutlineTabView(RetrieveAPIView): data = { 'access_expiration': access_expiration, + 'cert_data': cert_data, 'course_blocks': course_blocks, 'course_goals': course_goals, 'course_tools': course_tools, diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 6f5f6fa4f6..c5ab1c49b3 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -956,10 +956,6 @@ margin: 0; } - &.course-status-processing { - color: #0d7d4d; - } - .credit-action { .credit-btn { @extend %btn-pl-yellow-base; @@ -1071,6 +1067,11 @@ } } + &.course-status-earned-not-available, + &.course-status-certavailable { + color: #0d7d4d; + } + &.course-status-certavailable { .message-copy { width: flex-grid(6, 12); diff --git a/lms/templates/dashboard/_dashboard_certificate_information.html b/lms/templates/dashboard/_dashboard_certificate_information.html index 6b5856f3ed..3c2f988355 100644 --- a/lms/templates/dashboard/_dashboard_certificate_information.html +++ b/lms/templates/dashboard/_dashboard_certificate_information.html @@ -4,7 +4,7 @@ from django.utils.translation import ugettext as _ from openedx.core.djangolib.markup import HTML, Text from common.djangoapps.course_modes.models import CourseMode -from common.djangoapps.util.course import should_display_grade +from lms.djangoapps.certificates.data import CertificateStatuses %> <%namespace name='static' file='../static_content.html'/> @@ -20,7 +20,7 @@ from common.djangoapps.util.course import should_display_grade <% if cert_status['status'] == 'certificate_earned_but_not_available': - status_css_class = 'course-status-processing' + status_css_class = 'course-status-earned-not-available' elif cert_status['status'] == 'generating': status_css_class = 'course-status-certrendering' elif cert_status['status'] == 'downloadable': @@ -44,18 +44,11 @@ else:

% else: -
-

- % if should_display_grade(course_overview): - ${_("Your final grade:")} - ${"{0:.0f}%".format(float(cert_status['grade'])*100)}. - % elif course_overview.certificate_available_date and not course_overview.self_paced: - <% - cert_available_date = course_overview.certificate_available_date.strftime('%Y-%m-%d') - %> - ${_("Grades will be finalized on {cert_available_date}".format(cert_available_date=cert_available_date))} - % endif - % if cert_status['status'] == 'notpassing': +

+
+ % if cert_status['status'] == CertificateStatuses.downloadable: + ${_("Congratulations! Your certificate is ready.")} + % elif cert_status['status'] == 'notpassing': % if enrollment.mode != 'audit': ${_("Grade required for a {cert_name_short}:").format(cert_name_short=cert_name_short)} % else: @@ -76,8 +69,7 @@ else: ${Text(_("Verify your identity now."))}

% endif - -

+
% if cert_status['status'] == 'generating' or cert_status['status'] == 'downloadable' or cert_status['show_survey_button']:
@@ -92,7 +84,7 @@ else:
  • - ${_("View {cert_name_short}").format(cert_name_short=cert_name_short,)} + ${_("View my {cert_name_short}").format(cert_name_short=cert_name_short,)}
  • % elif cert_status['status'] == 'downloadable' and enrollment.mode in CourseMode.NON_VERIFIED_MODES: