Merge pull request #24864 from edx/ndalfonso/AA-196-course-celebration-cert
AA-196 course celebration cert.
This commit is contained in:
@@ -30,6 +30,7 @@ from lms.djangoapps.certificates.models import (
|
||||
)
|
||||
from lms.djangoapps.certificates.queue import XQueueCertInterface
|
||||
from lms.djangoapps.instructor.access import list_with_level
|
||||
from openedx.core.djangoapps.certificates.api import certificates_viewable_for_course
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from util.organizations_helpers import get_course_organization_id
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -308,8 +309,16 @@ def certificate_downloadable_status(student, course_key):
|
||||
'download_url': None,
|
||||
'uuid': None,
|
||||
}
|
||||
may_view_certificate = CourseOverview.get_from_id(course_key).may_certify()
|
||||
|
||||
course_overview = CourseOverview.get_from_id(course_key)
|
||||
if (
|
||||
not certificates_viewable_for_course(course_overview) and
|
||||
(current_status['status'] in CertificateStatuses.PASSED_STATUSES) and
|
||||
course_overview.certificate_available_date
|
||||
):
|
||||
response_data['earned_but_not_available'] = True
|
||||
|
||||
may_view_certificate = course_overview.may_certify()
|
||||
if current_status['status'] == CertificateStatuses.downloadable and may_view_certificate:
|
||||
response_data['is_downloadable'] = True
|
||||
response_data['download_url'] = current_status['download_url'] or get_certificate_url(
|
||||
|
||||
@@ -206,13 +206,13 @@ class CertificateDownloadableStatusTests(WebCertificateTestMixin, ModuleStoreTes
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
(False, timedelta(days=2), False),
|
||||
(False, -timedelta(days=2), True),
|
||||
(True, timedelta(days=2), True)
|
||||
(False, timedelta(days=2), False, True),
|
||||
(False, -timedelta(days=2), True, None),
|
||||
(True, timedelta(days=2), True, None)
|
||||
)
|
||||
@ddt.unpack
|
||||
@patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True})
|
||||
def test_cert_api_return(self, self_paced, cert_avail_delta, cert_downloadable_status):
|
||||
def test_cert_api_return(self, self_paced, cert_avail_delta, cert_downloadable_status, earned_but_not_available):
|
||||
"""
|
||||
Test 'downloadable status'
|
||||
"""
|
||||
@@ -226,10 +226,9 @@ class CertificateDownloadableStatusTests(WebCertificateTestMixin, ModuleStoreTes
|
||||
with mock_passing_grade():
|
||||
certs_api.generate_user_certificates(self.student, self.course.id)
|
||||
|
||||
self.assertEqual(
|
||||
certs_api.certificate_downloadable_status(self.student, self.course.id)['is_downloadable'],
|
||||
cert_downloadable_status
|
||||
)
|
||||
downloadable_status = certs_api.certificate_downloadable_status(self.student, self.course.id)
|
||||
self.assertEqual(downloadable_status['is_downloadable'], cert_downloadable_status)
|
||||
self.assertEqual(downloadable_status.get('earned_but_not_available'), earned_but_not_available)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
|
||||
@@ -52,19 +52,12 @@ class ChapterSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class CertificateDataSerializer(serializers.Serializer):
|
||||
cert_status = serializers.CharField()
|
||||
cert_web_view_url = serializers.CharField()
|
||||
download_url = serializers.CharField()
|
||||
is_downloadable = serializers.SerializerMethodField()
|
||||
is_requestable = serializers.SerializerMethodField()
|
||||
msg = serializers.CharField()
|
||||
title = serializers.CharField()
|
||||
|
||||
def get_is_downloadable(self, cert_data):
|
||||
return cert_data.cert_status == CertificateStatuses.downloadable and cert_data.download_url is not None
|
||||
|
||||
def get_is_requestable(self, cert_data):
|
||||
return cert_data.cert_status == CertificateStatuses.requesting and cert_data.request_cert_url is not None
|
||||
|
||||
|
||||
class CreditRequirementSerializer(serializers.Serializer):
|
||||
"""
|
||||
|
||||
@@ -46,6 +46,9 @@ class ProgressTabTestViews(BaseCourseHomeTests):
|
||||
ManualVerification.objects.create(user=self.user, status='approved')
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.data['verification_data']['status'], 'approved')
|
||||
self.assertIsNone(response.data['certificate_data'])
|
||||
elif enrollment_mode == CourseMode.AUDIT:
|
||||
self.assertEqual(response.data['certificate_data']['cert_status'], 'audit_passing')
|
||||
|
||||
def test_get_authenticated_user_not_enrolled(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
@@ -1759,6 +1759,20 @@ class ProgressPageTests(ProgressPageBaseTests):
|
||||
self.assertEqual(response.cert_status, 'requesting')
|
||||
self.assertEqual(response.title, "Congratulations, you qualified for a certificate!")
|
||||
|
||||
def test_earned_but_not_available_get_cert_data(self):
|
||||
"""
|
||||
Verify that earned but not available cert data is returned if cert has been earned, but isn't available.
|
||||
"""
|
||||
self.generate_certificate(
|
||||
"http://www.example.com/certificate.pdf", "verified"
|
||||
)
|
||||
with patch('lms.djangoapps.certificates.api.certificate_downloadable_status',
|
||||
return_value=self.mock_certificate_downloadable_status(earned_but_not_available=True)):
|
||||
response = views.get_cert_data(self.user, self.course, CourseMode.VERIFIED, MagicMock(passed=True))
|
||||
|
||||
self.assertEqual(response.cert_status, 'earned_but_not_available')
|
||||
self.assertEqual(response.title, "Your certificate will be available soon!")
|
||||
|
||||
def assert_invalidate_certificate(self, certificate):
|
||||
""" Dry method to mark certificate as invalid. And assert the response. """
|
||||
CertificateInvalidationFactory.create(
|
||||
@@ -1790,7 +1804,8 @@ class ProgressPageTests(ProgressPageBaseTests):
|
||||
return generated_certificate
|
||||
|
||||
def mock_certificate_downloadable_status(
|
||||
self, is_downloadable=False, is_generating=False, is_unverified=False, uuid=None, download_url=None
|
||||
self, is_downloadable=False, is_generating=False, is_unverified=False, uuid=None, download_url=None,
|
||||
earned_but_not_available=None,
|
||||
):
|
||||
"""Dry method to mock certificate downloadable status response."""
|
||||
return {
|
||||
@@ -1799,6 +1814,7 @@ class ProgressPageTests(ProgressPageBaseTests):
|
||||
'is_unverified': is_unverified,
|
||||
'download_url': uuid,
|
||||
'uuid': download_url,
|
||||
'earned_but_not_available': earned_but_not_available,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -38,3 +38,26 @@ REDIRECT_TO_COURSEWARE_MICROFRONTEND = ExperimentWaffleFlag(
|
||||
COURSEWARE_MICROFRONTEND_COURSE_TEAM_PREVIEW = CourseWaffleFlag(
|
||||
WAFFLE_FLAG_NAMESPACE, 'microfrontend_course_team_preview', __name__
|
||||
)
|
||||
|
||||
# Waffle flag to enable the course exit page in the learning MFE.
|
||||
#
|
||||
# .. toggle_name: courseware.microfrontend_course_exit_page
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Supports staged rollout of the new micro-frontend-based implementation of the course exit page.
|
||||
# .. toggle_category: micro-frontend
|
||||
# .. toggle_use_cases: incremental_release, open_edx
|
||||
# .. toggle_creation_date: 2020-10-02
|
||||
# .. toggle_target_removal_date: n/a
|
||||
# .. toggle_warnings: Also set settings.LEARNING_MICROFRONTEND_URL and ENABLE_COURSEWARE_MICROFRONTEND.
|
||||
# .. toggle_tickets: AA-188
|
||||
COURSEWARE_MICROFRONTEND_COURSE_EXIT_PAGE = CourseWaffleFlag(
|
||||
WAFFLE_FLAG_NAMESPACE, 'microfrontend_course_exit_page', __name__
|
||||
)
|
||||
|
||||
|
||||
def course_exit_page_is_active(course_key):
|
||||
return (
|
||||
REDIRECT_TO_COURSEWARE_MICROFRONTEND.is_enabled(course_key) and
|
||||
COURSEWARE_MICROFRONTEND_COURSE_EXIT_PAGE.is_enabled(course_key)
|
||||
)
|
||||
|
||||
@@ -144,6 +144,7 @@ REQUIREMENTS_DISPLAY_MODES = CourseMode.CREDIT_MODES + [CourseMode.VERIFIED]
|
||||
CertData = namedtuple(
|
||||
"CertData", ["cert_status", "title", "msg", "download_url", "cert_web_view_url"]
|
||||
)
|
||||
EARNED_BUT_NOT_AVAILABLE_CERT_STATUS = 'earned_but_not_available'
|
||||
|
||||
AUDIT_PASSING_CERT_DATA = CertData(
|
||||
CertificateStatuses.audit_passing,
|
||||
@@ -204,6 +205,14 @@ UNVERIFIED_CERT_DATA = CertData(
|
||||
cert_web_view_url=None
|
||||
)
|
||||
|
||||
EARNED_BUT_NOT_AVAILABLE_CERT_DATA = CertData(
|
||||
EARNED_BUT_NOT_AVAILABLE_CERT_STATUS,
|
||||
_('Your certificate will be available soon!'),
|
||||
_('After this course officially ends, you will receive an email notification with your certificate.'),
|
||||
download_url=None,
|
||||
cert_web_view_url=None
|
||||
)
|
||||
|
||||
|
||||
def _downloadable_cert_data(download_url=None, cert_web_view_url=None):
|
||||
return CertData(
|
||||
@@ -1206,6 +1215,9 @@ def _certificate_message(student, course, enrollment_mode):
|
||||
|
||||
cert_downloadable_status = certs_api.certificate_downloadable_status(student, course.id)
|
||||
|
||||
if cert_downloadable_status.get('earned_but_not_available'):
|
||||
return EARNED_BUT_NOT_AVAILABLE_CERT_DATA
|
||||
|
||||
if cert_downloadable_status['is_generating']:
|
||||
return GENERATING_CERT_DATA
|
||||
|
||||
@@ -1235,6 +1247,9 @@ def get_cert_data(student, course, enrollment_mode, course_grade=None):
|
||||
if not CourseMode.is_eligible_for_certificate(enrollment_mode, status=cert_data.cert_status):
|
||||
return INELIGIBLE_PASSING_CERT_DATA.get(enrollment_mode)
|
||||
|
||||
if cert_data.cert_status == EARNED_BUT_NOT_AVAILABLE_CERT_STATUS:
|
||||
return cert_data
|
||||
|
||||
certificates_enabled_for_course = certs_api.cert_generation_enabled(course.id)
|
||||
if course_grade is None:
|
||||
course_grade = CourseGradeFactory().read(student, course)
|
||||
|
||||
@@ -4,6 +4,7 @@ Course API Serializers. Representing course catalog data
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from lms.djangoapps.course_home_api.progress.v1.serializers import CertificateDataSerializer
|
||||
from openedx.core.lib.api.fields import AbsoluteURLField
|
||||
|
||||
|
||||
@@ -90,6 +91,10 @@ class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract-
|
||||
notes = serializers.DictField()
|
||||
marketing_url = serializers.CharField()
|
||||
celebrations = serializers.DictField()
|
||||
user_has_passing_grade = serializers.BooleanField()
|
||||
course_exit_page_is_active = serializers.BooleanField()
|
||||
certificate_data = CertificateDataSerializer()
|
||||
verify_identity_url = AbsoluteURLField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -8,6 +8,7 @@ import ddt
|
||||
import mock
|
||||
from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
from lms.djangoapps.courseware.access_utils import ACCESS_DENIED, ACCESS_GRANTED
|
||||
from lms.djangoapps.courseware.tabs import ExternalLinkCourseTab
|
||||
@@ -105,6 +106,20 @@ class CourseApiTestViews(BaseCoursewareTests):
|
||||
if tab['url'] == 'http://zombo.com':
|
||||
found = True
|
||||
assert found, 'external link not in course tabs'
|
||||
|
||||
assert not response.data['user_has_passing_grade']
|
||||
if enrollment_mode == 'audit':
|
||||
# This message comes from AUDIT_PASSING_CERT_DATA in lms/djangoapps/courseware/views/views.py
|
||||
expected_audit_message = ('You are enrolled in the audit track for this course. '
|
||||
'The audit track does not include a certificate.')
|
||||
assert response.data['certificate_data']['msg'] == expected_audit_message
|
||||
assert response.data['verify_identity_url'] is None
|
||||
else:
|
||||
# Not testing certificate data for verified learner here. That is tested elsewhere
|
||||
assert response.data['certificate_data'] is None
|
||||
expected_verify_identity_url = reverse('verify_student_verify_now', args=[self.course.id])
|
||||
# The response contains an absolute URL so this is only checking the path of the final
|
||||
assert expected_verify_identity_url in response.data['verify_identity_url']
|
||||
elif enable_anonymous and not logged_in:
|
||||
# multiple checks use this handler
|
||||
check_public_access.assert_called()
|
||||
|
||||
@@ -30,9 +30,12 @@ from lms.djangoapps.courseware.courses import check_course_access, get_course_by
|
||||
from lms.djangoapps.courseware.masquerade import setup_masquerade
|
||||
from lms.djangoapps.courseware.module_render import get_module_by_usage_id
|
||||
from lms.djangoapps.courseware.tabs import get_course_tab_list
|
||||
from lms.djangoapps.courseware.toggles import REDIRECT_TO_COURSEWARE_MICROFRONTEND
|
||||
from lms.djangoapps.courseware.toggles import REDIRECT_TO_COURSEWARE_MICROFRONTEND, course_exit_page_is_active
|
||||
from lms.djangoapps.courseware.utils import can_show_verified_upgrade
|
||||
from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link
|
||||
from lms.djangoapps.courseware.views.views import get_cert_data
|
||||
from lms.djangoapps.grades.api import CourseGradeFactory
|
||||
from lms.djangoapps.verify_student.services import IDVerificationService
|
||||
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
|
||||
from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG
|
||||
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
|
||||
@@ -55,18 +58,16 @@ class CoursewareMeta:
|
||||
username or request.user.username,
|
||||
course_key,
|
||||
)
|
||||
self.effective_user = self.overview.effective_user
|
||||
self.original_user_is_staff = has_access(request.user, 'staff', self.overview).has_access
|
||||
self.course_key = course_key
|
||||
self.enrollment_object = CourseEnrollment.get_enrollment(self.effective_user, self.course_key,
|
||||
select_related=['celebration'])
|
||||
course_masquerade, user = setup_masquerade(
|
||||
self.course_masquerade, self.effective_user = setup_masquerade(
|
||||
request,
|
||||
course_key,
|
||||
staff_access=self.original_user_is_staff,
|
||||
)
|
||||
self.is_staff = has_access(user, 'staff', self.overview).has_access
|
||||
self.course_masquerade = course_masquerade
|
||||
self.is_staff = has_access(self.effective_user, 'staff', self.overview).has_access
|
||||
self.enrollment_object = CourseEnrollment.get_enrollment(self.effective_user, self.course_key,
|
||||
select_related=['celebration'])
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.overview, name)
|
||||
@@ -104,14 +105,12 @@ class CoursewareMeta:
|
||||
"""
|
||||
Return enrollment information.
|
||||
"""
|
||||
if self.effective_user.is_anonymous:
|
||||
if self.effective_user.is_anonymous or not self.enrollment_object:
|
||||
mode = None
|
||||
is_active = False
|
||||
else:
|
||||
mode, is_active = CourseEnrollment.enrollment_mode_for_user(
|
||||
self.effective_user,
|
||||
self.course_key
|
||||
)
|
||||
mode = self.enrollment_object.mode
|
||||
is_active = self.enrollment_object.is_active
|
||||
return {'mode': mode, 'is_active': is_active}
|
||||
|
||||
@property
|
||||
@@ -208,6 +207,43 @@ class CoursewareMeta:
|
||||
'first_section': CourseEnrollmentCelebration.should_celebrate_first_section(self.enrollment_object),
|
||||
}
|
||||
|
||||
@property
|
||||
def user_has_passing_grade(self):
|
||||
""" Returns a boolean on if the effective_user has a passing grade in the course """
|
||||
course = get_course_by_id(self.course_key)
|
||||
user_grade = CourseGradeFactory().read(self.effective_user, course).percent
|
||||
return user_grade >= course.lowest_passing_grade
|
||||
|
||||
@property
|
||||
def course_exit_page_is_active(self):
|
||||
""" Returns a boolean on if the course exit page is active """
|
||||
return course_exit_page_is_active(self.course_key)
|
||||
|
||||
@property
|
||||
def certificate_data(self):
|
||||
"""
|
||||
Returns certificate data if the effective_user is enrolled.
|
||||
Note: certificate data can be None depending on learner and/or course state.
|
||||
"""
|
||||
course = get_course_by_id(self.course_key)
|
||||
if self.enrollment_object:
|
||||
return get_cert_data(self.effective_user, course, self.enrollment_object.mode)
|
||||
|
||||
@property
|
||||
def verify_identity_url(self):
|
||||
"""
|
||||
Returns a String to the location to verify a learner's identity
|
||||
Note: This might return an absolute URL (if the verification MFE is enabled) or a relative
|
||||
URL. The serializer will make the relative URL absolute so any consumers can treat this
|
||||
as a full URL.
|
||||
"""
|
||||
if self.enrollment_object and self.enrollment_object.mode in CourseMode.VERIFIED_MODES:
|
||||
verification_status = IDVerificationService.user_status(self.effective_user)['status']
|
||||
if verification_status == 'must_reverify':
|
||||
return IDVerificationService.get_verify_location('verify_student_reverify')
|
||||
else:
|
||||
return IDVerificationService.get_verify_location('verify_student_verify_now', self.course_key)
|
||||
|
||||
|
||||
class CoursewareInformation(RetrieveAPIView):
|
||||
"""
|
||||
@@ -252,6 +288,12 @@ class CoursewareInformation(RetrieveAPIView):
|
||||
* can_load_course: Whether the user can view the course (AccessResponse object)
|
||||
* is_staff: Whether the effective user has staff access to the course
|
||||
* original_user_is_staff: Whether the original user has staff access to the course
|
||||
* user_has_passing_grade: Whether or not the effective user's grade is equal to or above the courses minimum
|
||||
passing grade
|
||||
* course_exit_page_is_active: Flag for the learning mfe on whether or not the course exit page should display
|
||||
* certificate_data: data regarding the effective user's certificate for the given course
|
||||
* verify_identity_url: URL for a learner to verify their identity. Only returned for learners enrolled in a
|
||||
verified mode. Will update to reverify URL if necessary.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
set -e
|
||||
|
||||
export LOWER_PYLINT_THRESHOLD=1000
|
||||
export UPPER_PYLINT_THRESHOLD=2560
|
||||
export UPPER_PYLINT_THRESHOLD=2500
|
||||
export ESLINT_THRESHOLD=5530
|
||||
export STYLELINT_THRESHOLD=880
|
||||
|
||||
Reference in New Issue
Block a user