Certificate Display Settings revamp (round 2) (#28286)
feat: reimagine certificate display settings The course settings `certificate_available_date` (CAD) and `certificates_display_behavior` (CDB) were previously acting indedependantly of one another. They now work in tandem. This change: - limits CDB to a dropdown - removes "early_with_info" and adds "end_with_date" - only takes CAD into account if "end_with_date" is selected - Moves CDB to the main course schedule settings page - updates CourseOverview model and CourseDetails objects to validate these fields and choose sane defaults if they aren't expected values This work was previously done in bd9e7dd (complete with bugs), so this version is toggleable via the ENABLE_V2_CERT_DISPLAY_SETTINGS setting
This commit is contained in:
@@ -51,6 +51,7 @@ from openedx.core.djangoapps.certificates.api import auto_certificate_generation
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.theming.helpers import get_themes
|
||||
from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
|
||||
# Enumeration of per-course verification statuses
|
||||
# we display on the student dashboard.
|
||||
@@ -513,15 +514,11 @@ def _cert_info(user, enrollment, cert_status):
|
||||
status = template_state.get(cert_status['status'], default_status)
|
||||
is_hidden_status = status in ('processing', 'generating', 'notpassing', 'auditing')
|
||||
|
||||
if (
|
||||
not certificates_viewable_for_course(course_overview) and
|
||||
CertificateStatuses.is_passing_status(status) and
|
||||
course_overview.certificate_available_date
|
||||
):
|
||||
if _is_certificate_earned_but_not_available(course_overview, status):
|
||||
status = certificate_earned_but_not_available_status
|
||||
|
||||
if (
|
||||
course_overview.certificates_display_behavior == 'early_no_info' and
|
||||
course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.EARLY_NO_INFO and
|
||||
is_hidden_status
|
||||
):
|
||||
return default_info
|
||||
@@ -610,6 +607,36 @@ def _cert_info(user, enrollment, cert_status):
|
||||
return status_dict
|
||||
|
||||
|
||||
def _is_certificate_earned_but_not_available(course_overview, status):
|
||||
"""
|
||||
Returns True if the user is passing the course, but the certificate is not visible due to display behavior or
|
||||
available date
|
||||
|
||||
Params:
|
||||
course_overview (CourseOverview): The course to check we're checking the certificate for
|
||||
status (str): The certificate status the user has in the course
|
||||
|
||||
Returns:
|
||||
(bool): True if the user earned the certificate but it's hidden due to display behavior, else False
|
||||
|
||||
"""
|
||||
if settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS"):
|
||||
return (
|
||||
not certificates_viewable_for_course(course_overview)
|
||||
and CertificateStatuses.is_passing_status(status)
|
||||
and course_overview.certificates_display_behavior in (
|
||||
CertificatesDisplayBehaviors.END_WITH_DATE,
|
||||
CertificatesDisplayBehaviors.END
|
||||
)
|
||||
)
|
||||
else:
|
||||
return (
|
||||
not certificates_viewable_for_course(course_overview) and
|
||||
CertificateStatuses.is_passing_status(status) and
|
||||
course_overview.certificate_available_date
|
||||
)
|
||||
|
||||
|
||||
def process_survey_link(survey_link, user):
|
||||
"""
|
||||
If {UNIQUE_ID} appears in the link, replace it with a unique id for the user.
|
||||
|
||||
@@ -22,6 +22,7 @@ from lms.djangoapps.certificates.tests.factories import (
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
@@ -40,7 +41,7 @@ class CertificateDisplayTestBase(SharedModuleStoreTestCase):
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.course = CourseFactory()
|
||||
cls.course.certificates_display_behavior = "early_with_info"
|
||||
cls.course.certificates_display_behavior = CertificatesDisplayBehaviors.EARLY_NO_INFO
|
||||
|
||||
with cls.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, cls.course.id):
|
||||
cls.store.update_item(cls.course, cls.USERNAME)
|
||||
@@ -116,40 +117,54 @@ class CertificateDashboardMessageDisplayTest(CertificateDisplayTestBase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.course.certificates_display_behavior = "end"
|
||||
cls.course.certificates_display_behavior = CertificatesDisplayBehaviors.END_WITH_DATE
|
||||
cls.course.save()
|
||||
cls.store.update_item(cls.course, cls.USERNAME)
|
||||
|
||||
def _check_message(self, certificate_available_date): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
def _check_message(self, visible_date): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
response = self.client.get(reverse('dashboard'))
|
||||
test_message = 'Your grade and certificate will be ready after'
|
||||
if certificate_available_date is None:
|
||||
|
||||
is_past = visible_date < datetime.datetime.now(UTC)
|
||||
|
||||
if is_past:
|
||||
self.assertNotContains(response, test_message)
|
||||
self.assertNotContains(response, "View Test_Certificate")
|
||||
elif datetime.datetime.now(UTC) < certificate_available_date:
|
||||
self.assertContains(response, test_message)
|
||||
self.assertNotContains(response, "View Test_Certificate")
|
||||
else:
|
||||
self._check_can_download_certificate()
|
||||
|
||||
@ddt.data(True, False, None)
|
||||
def test_certificate_available_date(self, past_certificate_available_date):
|
||||
else:
|
||||
self.assertContains(response, test_message)
|
||||
self.assertNotContains(response, "View Test_Certificate")
|
||||
|
||||
@ddt.data(
|
||||
(CertificatesDisplayBehaviors.END, True),
|
||||
(CertificatesDisplayBehaviors.END, False),
|
||||
(CertificatesDisplayBehaviors.END_WITH_DATE, True),
|
||||
(CertificatesDisplayBehaviors.END_WITH_DATE, False)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_certificate_available_date(self, certificates_display_behavior, past_date):
|
||||
cert = self._create_certificate('verified')
|
||||
cert.status = CertificateStatuses.downloadable
|
||||
cert.save()
|
||||
|
||||
if past_certificate_available_date is None:
|
||||
certificate_available_date = None
|
||||
elif past_certificate_available_date:
|
||||
certificate_available_date = PAST_DATE
|
||||
elif not past_certificate_available_date:
|
||||
certificate_available_date = FUTURE_DATE
|
||||
self.course.certificates_display_behavior = certificates_display_behavior
|
||||
|
||||
if certificates_display_behavior == CertificatesDisplayBehaviors.END:
|
||||
if past_date:
|
||||
self.course.end = PAST_DATE
|
||||
else:
|
||||
self.course.end = FUTURE_DATE
|
||||
if certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE:
|
||||
if past_date:
|
||||
self.course.certificate_available_date = PAST_DATE
|
||||
else:
|
||||
self.course.certificate_available_date = FUTURE_DATE
|
||||
|
||||
self.course.certificate_available_date = certificate_available_date
|
||||
self.course.save()
|
||||
self.store.update_item(self.course, self.USERNAME)
|
||||
|
||||
self._check_message(certificate_available_date)
|
||||
self._check_message(PAST_DATE if past_date else FUTURE_DATE)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
|
||||
@@ -40,6 +40,7 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import Cou
|
||||
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context
|
||||
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
|
||||
from openedx.features.course_experience.tests.views.helpers import add_course_mode
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
@@ -230,6 +231,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin,
|
||||
id=course_key,
|
||||
end_date=THREE_YEARS_AGO,
|
||||
certificate_available_date=TOMORROW,
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.END_WITH_DATE,
|
||||
lowest_passing_grade=0.3
|
||||
)
|
||||
CourseEnrollmentFactory(course_id=course.id, user=self.user, mode=CourseMode.VERIFIED)
|
||||
@@ -245,6 +247,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin,
|
||||
id=course_key,
|
||||
end_date=TOMORROW,
|
||||
certificate_available_date=TOMORROW,
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.END_WITH_DATE,
|
||||
lowest_passing_grade=0.3
|
||||
)
|
||||
CourseEnrollmentFactory(course_id=course.id, user=self.user, mode=CourseMode.VERIFIED)
|
||||
@@ -260,6 +263,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin,
|
||||
id=course_key,
|
||||
end_date=ONE_WEEK_AGO,
|
||||
certificate_available_date=now(),
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.END_WITH_DATE,
|
||||
lowest_passing_grade=0.3
|
||||
)
|
||||
CourseEnrollmentFactory(course_id=course.id, user=self.user, mode=CourseMode.VERIFIED)
|
||||
|
||||
@@ -49,6 +49,8 @@ from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreEnum, ModuleStoreTestCase, SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -75,7 +77,8 @@ class CourseEndingTest(ModuleStoreTestCase):
|
||||
survey_url = "http://a_survey.com"
|
||||
course = CourseOverviewFactory.create(
|
||||
end_of_course_survey_url=survey_url,
|
||||
certificates_display_behavior='end',
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.END,
|
||||
end=datetime.now(pytz.UTC) - timedelta(days=2)
|
||||
)
|
||||
cert = GeneratedCertificateFactory.create(
|
||||
user=user,
|
||||
@@ -152,6 +155,7 @@ class CourseEndingTest(ModuleStoreTestCase):
|
||||
)
|
||||
enrollment3 = CourseEnrollmentFactory(user=user, course_id=course3.id, mode=CourseMode.VERIFIED)
|
||||
# test when the display is unavailable or notpassing, we get the correct results out
|
||||
course2.certificates_display_behavior = CertificatesDisplayBehaviors.EARLY_NO_INFO
|
||||
cert_status = {'status': 'unavailable', 'mode': 'honor', 'uuid': None}
|
||||
assert _cert_info(user, enrollment3, cert_status) == {'status': 'processing', 'show_survey_button': False,
|
||||
'can_unenroll': True}
|
||||
@@ -186,7 +190,8 @@ class CourseEndingTest(ModuleStoreTestCase):
|
||||
survey_url = "http://a_survey.com"
|
||||
course = CourseOverviewFactory.create(
|
||||
end_of_course_survey_url=survey_url,
|
||||
certificates_display_behavior='end',
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.END,
|
||||
end=datetime.now(pytz.UTC) - timedelta(days=2),
|
||||
)
|
||||
enrollment = CourseEnrollmentFactory(user=user, course_id=course.id, mode=CourseMode.VERIFIED)
|
||||
|
||||
@@ -212,7 +217,8 @@ class CourseEndingTest(ModuleStoreTestCase):
|
||||
survey_url = "http://a_survey.com"
|
||||
course = CourseOverviewFactory.create(
|
||||
end_of_course_survey_url=survey_url,
|
||||
certificates_display_behavior='end',
|
||||
certificates_display_behavior=CertificatesDisplayBehaviors.END,
|
||||
end=datetime.now(pytz.UTC) - timedelta(days=2),
|
||||
)
|
||||
cert_status = {'status': 'generating', 'mode': 'honor', 'uuid': None}
|
||||
enrollment = CourseEnrollmentFactory(user=user, course_id=course.id, mode=CourseMode.VERIFIED)
|
||||
|
||||
@@ -22,6 +22,7 @@ from openedx.core.lib.license import LicenseMixin
|
||||
from openedx.core.lib.teams_config import TeamsConfig # lint-amnesty, pylint: disable=unused-import
|
||||
from xmodule import course_metadata_utils
|
||||
from xmodule.course_metadata_utils import DEFAULT_GRADING_POLICY, DEFAULT_START_DATE
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
from xmodule.graders import grader_from_conf
|
||||
from xmodule.seq_module import SequenceBlock
|
||||
from xmodule.tabs import CourseTabList, InvalidTabsException
|
||||
@@ -555,15 +556,11 @@ class CourseFields: # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
certificates_display_behavior = String(
|
||||
display_name=_("Certificates Display Behavior"),
|
||||
help=_(
|
||||
"Enter end, early_with_info, or early_no_info. After certificate generation, students who passed see a "
|
||||
"link to their certificates on the dashboard and students who did not pass see information about the "
|
||||
"grading configuration. The default is end, which displays this certificate information to all students "
|
||||
"after the course end date. To display this certificate information to all students as soon as "
|
||||
"certificates are generated, enter early_with_info. To display only the links to passing students as "
|
||||
"soon as certificates are generated, enter early_no_info."
|
||||
"This field, together with certificate_available_date will determine when a "
|
||||
"user can see their certificate for the course"
|
||||
),
|
||||
scope=Scope.settings,
|
||||
default="end"
|
||||
default=CertificatesDisplayBehaviors.END,
|
||||
)
|
||||
course_image = String(
|
||||
display_name=_("Course About Page Image"),
|
||||
@@ -1061,7 +1058,8 @@ class CourseBlock(
|
||||
except InvalidTabsException as err:
|
||||
raise type(err)(f'{str(err)} For course: {str(self.id)}') # lint-amnesty, pylint: disable=line-too-long
|
||||
|
||||
self.set_default_certificate_available_date()
|
||||
if not settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS"):
|
||||
self.set_default_certificate_available_date()
|
||||
|
||||
def set_grading_policy(self, course_policy):
|
||||
"""
|
||||
|
||||
25
common/lib/xmodule/xmodule/data.py
Normal file
25
common/lib/xmodule/xmodule/data.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Public data structures for this app.
|
||||
|
||||
See OEP-49 for details
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CertificatesDisplayBehaviors(str, Enum):
|
||||
"""
|
||||
Options for the certificates_display_behavior field of a course
|
||||
|
||||
end: Certificates are available at the end of the course
|
||||
end_with_date: Certificates are available after the certificate_available_date (post course end)
|
||||
early_no_info: Certificates are available immediately after earning them.
|
||||
|
||||
Only in affect for instructor based courses.
|
||||
"""
|
||||
END = "end"
|
||||
END_WITH_DATE = "end_with_date"
|
||||
EARLY_NO_INFO = "early_no_info"
|
||||
|
||||
@classmethod
|
||||
def includes_value(cls, value):
|
||||
return value in set(item.value for item in cls)
|
||||
@@ -18,6 +18,7 @@ from xblock.runtime import DictKeyValueStore, KvsFieldData
|
||||
|
||||
from openedx.core.lib.teams_config import TeamsConfig, DEFAULT_COURSE_RUN_MAX_TEAM_SIZE
|
||||
import xmodule.course_module
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
|
||||
from xmodule.modulestore.exceptions import InvalidProctoringProvider
|
||||
|
||||
@@ -105,36 +106,41 @@ class HasEndedMayCertifyTestCase(unittest.TestCase):
|
||||
super().setUp()
|
||||
|
||||
system = DummySystem(load_error_modules=True) # lint-amnesty, pylint: disable=unused-variable
|
||||
#sample_xml = """
|
||||
# <course org="{org}" course="{course}" display_organization="{org}_display" display_coursenumber="{course}_display" # lint-amnesty, pylint: disable=line-too-long
|
||||
# graceperiod="1 day" url_name="test"
|
||||
# start="2012-01-01T12:00"
|
||||
# {end}
|
||||
# certificates_show_before_end={cert}>
|
||||
# <chapter url="hi" url_name="ch" display_name="CH">
|
||||
# <html url_name="h" display_name="H">Two houses, ...</html>
|
||||
# </chapter>
|
||||
# </course>
|
||||
#""".format(org=ORG, course=COURSE)
|
||||
|
||||
past_end = (datetime.now() - timedelta(days=12)).strftime("%Y-%m-%dT%H:%M:00")
|
||||
future_end = (datetime.now() + timedelta(days=12)).strftime("%Y-%m-%dT%H:%M:00")
|
||||
self.past_show_certs = get_dummy_course("2012-01-01T12:00", end=past_end, certs='early_with_info')
|
||||
self.past_show_certs_no_info = get_dummy_course("2012-01-01T12:00", end=past_end, certs='early_no_info')
|
||||
self.past_noshow_certs = get_dummy_course("2012-01-01T12:00", end=past_end, certs='end')
|
||||
self.future_show_certs = get_dummy_course("2012-01-01T12:00", end=future_end, certs='early_with_info')
|
||||
self.future_show_certs_no_info = get_dummy_course("2012-01-01T12:00", end=future_end, certs='early_no_info')
|
||||
self.future_noshow_certs = get_dummy_course("2012-01-01T12:00", end=future_end, certs='end')
|
||||
#self.past_show_certs = system.process_xml(sample_xml.format(end=past_end, cert=True))
|
||||
#self.past_noshow_certs = system.process_xml(sample_xml.format(end=past_end, cert=False))
|
||||
#self.future_show_certs = system.process_xml(sample_xml.format(end=future_end, cert=True))
|
||||
#self.future_noshow_certs = system.process_xml(sample_xml.format(end=future_end, cert=False))
|
||||
self.past_show_certs = get_dummy_course(
|
||||
"2012-01-01T12:00",
|
||||
end=past_end,
|
||||
certs=CertificatesDisplayBehaviors.EARLY_NO_INFO
|
||||
)
|
||||
self.past_show_certs_no_info = get_dummy_course(
|
||||
"2012-01-01T12:00",
|
||||
end=past_end,
|
||||
certs=CertificatesDisplayBehaviors.EARLY_NO_INFO
|
||||
)
|
||||
self.past_noshow_certs = get_dummy_course(
|
||||
"2012-01-01T12:00",
|
||||
end=past_end,
|
||||
certs=CertificatesDisplayBehaviors.END
|
||||
)
|
||||
|
||||
self.future_show_certs_no_info = get_dummy_course(
|
||||
"2012-01-01T12:00",
|
||||
end=future_end,
|
||||
certs=CertificatesDisplayBehaviors.EARLY_NO_INFO
|
||||
)
|
||||
self.future_noshow_certs = get_dummy_course(
|
||||
"2012-01-01T12:00",
|
||||
end=future_end,
|
||||
certs=CertificatesDisplayBehaviors.END
|
||||
)
|
||||
|
||||
def test_has_ended(self):
|
||||
"""Check that has_ended correctly tells us when a course is over."""
|
||||
assert self.past_show_certs.has_ended()
|
||||
assert self.past_show_certs_no_info.has_ended()
|
||||
assert self.past_noshow_certs.has_ended()
|
||||
assert not self.future_show_certs.has_ended()
|
||||
assert not self.future_show_certs_no_info.has_ended()
|
||||
assert not self.future_noshow_certs.has_ended()
|
||||
|
||||
@@ -402,14 +408,6 @@ class CourseBlockTestCase(unittest.TestCase):
|
||||
"""
|
||||
assert self.course.number == COURSE
|
||||
|
||||
def test_set_default_certificate_available_date(self):
|
||||
"""
|
||||
The certificate_available_date field should default to two days
|
||||
after the course end date.
|
||||
"""
|
||||
expected_certificate_available_date = self.course.end + timedelta(days=2)
|
||||
assert expected_certificate_available_date == self.course.certificate_available_date
|
||||
|
||||
|
||||
class ProctoringProviderTestCase(unittest.TestCase):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user