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:
Matt Tuchfarber
2021-08-02 11:30:15 -04:00
committed by GitHub
parent 121afd0c5f
commit 622d56026c
37 changed files with 851 additions and 188 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):
"""

View 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)

View File

@@ -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):
"""