diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 059fb4aa4b..dce1a75ab2 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -19,6 +19,13 @@ from xmodule.x_module import STUDENT_VIEW from microsite_configuration import microsite from courseware.access import has_access +from courseware.date_summary import ( + CourseEndDate, + CourseStartDate, + TodaysDate, + VerificationDeadlineDate, + VerifiedUpgradeDeadlineDate, +) from courseware.model_data import FieldDataCache from courseware.module_render import get_module from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException @@ -27,6 +34,7 @@ import branding from opaque_keys.edx.keys import UsageKey + log = logging.getLogger(__name__) @@ -301,6 +309,34 @@ def get_course_info_section(request, course, section_key): return html +def get_course_date_summary(course, user): + """ + Return the snippet of HTML to be included on the course info page + in the 'Date Summary' section. + """ + blocks = _get_course_date_summary_blocks(course, user) + return '\n'.join( + b.render() for b in blocks + ) + + +def _get_course_date_summary_blocks(course, user): + """ + Return the list of blocks to display on the course info page, + sorted by date. + """ + block_classes = ( + CourseEndDate, + CourseStartDate, + TodaysDate, + VerificationDeadlineDate, + VerifiedUpgradeDeadlineDate, + ) + + blocks = (cls(course, user) for cls in block_classes) + return sorted((b for b in blocks if b.is_enabled), key=lambda b: b.date) + + # TODO: Fix this such that these are pulled in as extra course-specific tabs. # arjun will address this by the end of October if no one does so prior to # then. diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py new file mode 100644 index 0000000000..68cc759a8f --- /dev/null +++ b/lms/djangoapps/courseware/date_summary.py @@ -0,0 +1,248 @@ +# pylint: disable=missing-docstring +""" +This module provides date summary blocks for the Course Info +page. Each block gives information about a particular +course-run-specific date which will be displayed to the user. +""" +from datetime import datetime + +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ +from edxmako.shortcuts import render_to_string +from lazy import lazy +import pytz + +from course_modes.models import CourseMode +from verify_student.models import VerificationDeadline, SoftwareSecurePhotoVerification +from student.models import CourseEnrollment + + +class DateSummary(object): + """Base class for all date summary blocks.""" + + # The CSS class of this summary. Indicates the type of information + # this summary block contains, and its urgency. + css_class = '' + + # The title of this summary. + title = '' + + # The detail text displayed by this summary. + description = '' + + # This summary's date. + date = None + + # The format to display this date in. By default, displays like Jan 01, 2015. + date_format = '%b %d, %Y' + + # The location to link to for more information. + link = '' + + # The text of the link. + link_text = '' + + def __init__(self, course, user): + self.course = course + self.user = user + + def get_context(self): + """Return the template context used to render this summary block.""" + date = '' + if self.date is not None: + date = self.date.strftime(self.date_format) + return { + 'title': self.title, + 'date': date, + 'description': self.description, + 'css_class': self.css_class, + 'link': self.link, + 'link_text': self.link_text, + } + + def render(self): + """ + Return an HTML representation of this summary block. + """ + return render_to_string('courseware/date_summary.html', self.get_context()) + + @property + def is_enabled(self): + """ + Whether or not this summary block should be shown. + + By default, the summary is only shown if its date is in the + future. + """ + if self.date is not None: + return datetime.now(pytz.UTC) <= self.date + return False + + def __repr__(self): + return 'DateSummary: "{title}" {date} is_enabled={is_enabled}'.format( + title=self.title, + date=self.date, + is_enabled=self.is_enabled + ) + + +class TodaysDate(DateSummary): + """ + Displays today's date. + """ + css_class = 'todays-date' + is_enabled = True + + # The date is shown in the title, no need to display it again. + def get_context(self): + context = super(TodaysDate, self).get_context() + context['date'] = '' + return context + + @property + def date(self): + return datetime.now(pytz.UTC) + + @property + def title(self): + return _('Today is {date}').format(date=datetime.now(pytz.UTC).strftime(self.date_format)) + + +class CourseStartDate(DateSummary): + """ + Displays the start date of the course. + """ + css_class = 'start-date' + title = _('Course Starts') + + @property + def date(self): + return self.course.start + + +class CourseEndDate(DateSummary): + """ + Displays the end date of the course. + """ + css_class = 'end-date' + title = _('Course End') + is_enabled = True + + @property + def description(self): + if datetime.now(pytz.UTC) <= self.date: + return _('To earn a certificate, you must complete all requirements before this date.') + return _('This course is archived, which means you can review course content but it is no longer active.') + + @property + def date(self): + return self.course.end + + +class VerifiedUpgradeDeadlineDate(DateSummary): + """ + Displays the date before which learners must upgrade to the + Verified track. + """ + css_class = 'verified-upgrade-deadline' + title = _('Verification Upgrade Deadline') + description = _('You are still eligible to upgrade to a Verified Certificate!') + link_text = _('Upgrade to Verified Certificate') + + @property + def link(self): + return reverse('verify_student_upgrade_and_verify', args=(self.course.id,)) + + @lazy + def date(self): + try: + verified_mode = CourseMode.objects.get( + course_id=self.course.id, mode_slug=CourseMode.VERIFIED + ) + return verified_mode.expiration_datetime + except CourseMode.DoesNotExist: + return None + + +class VerificationDeadlineDate(DateSummary): + """ + Displays the date by which the user must complete the verification + process. + """ + + @property + def css_class(self): + base_state = 'verification-deadline' + if self.deadline_has_passed(): + return base_state + '-passed' + elif self.must_retry(): + return base_state + '-retry' + else: + return base_state + '-upcoming' + + @property + def link_text(self): + return self.link_table[self.css_class][0] + + @property + def link(self): + return self.link_table[self.css_class][1] + + @property + def link_table(self): + """Maps verification state to a tuple of link text and location.""" + return { + 'verification-deadline-passed': (_('Learn More'), ''), + 'verification-deadline-retry': (_('Retry Verification'), reverse('verify_student_reverify')), + 'verification-deadline-upcoming': ( + _('Verify My Identity'), + reverse('verify_student_verify_now', args=(self.course.id,)) + ) + } + + @property + def title(self): + if self.deadline_has_passed(): + return _('Missed Verification Deadline') + return _('Verification Deadline') + + @property + def description(self): + if self.deadline_has_passed(): + return _( + "Unfortunately you missed this course's deadline for" + " a successful verification." + ) + return _( + "You must successfully complete verification before" + " this date to qualify for a Verified Certificate." + ) + + @lazy + def date(self): + return VerificationDeadline.deadline_for_course(self.course.id) + + @lazy + def is_enabled(self): + if self.date is None: + return False + (mode, is_active) = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) + if is_active and mode == 'verified': + return self.verification_status in ('expired', 'none', 'must_reverify') + return False + + @lazy + def verification_status(self): + """Return the verification status for this user.""" + return SoftwareSecurePhotoVerification.user_status(self.user)[0] + + def deadline_has_passed(self): + """ + Return True if a verification deadline exists, and has already passed. + """ + deadline = self.date + return deadline is not None and deadline <= datetime.now(pytz.UTC) + + def must_retry(self): + """Return True if the user must re-submit verification, False otherwise.""" + return self.verification_status == 'must_reverify' diff --git a/lms/djangoapps/courseware/tests/test_date_summary.py b/lms/djangoapps/courseware/tests/test_date_summary.py new file mode 100644 index 0000000000..d96c8b5c46 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_date_summary.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- +"""Tests for course home page date summary blocks.""" +from datetime import datetime, timedelta + +import ddt +from django.core.urlresolvers import reverse +import freezegun +from nose.plugins.attrib import attr +import pytz + +from course_modes.tests.factories import CourseModeFactory +from course_modes.models import CourseMode +from courseware.courses import _get_course_date_summary_blocks +from courseware.date_summary import ( + CourseEndDate, + CourseStartDate, + DateSummary, + TodaysDate, + VerificationDeadlineDate, + VerifiedUpgradeDeadlineDate, +) +from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration +from student.tests.factories import CourseEnrollmentFactory, UserFactory +from verify_student.models import VerificationDeadline +from verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +@attr('shard_1') +@ddt.ddt +class CourseDateSummaryTest(SharedModuleStoreTestCase): + """Tests for course date summary blocks.""" + + def setUp(self): + SelfPacedConfiguration(enable_course_home_improvements=True).save() + super(CourseDateSummaryTest, self).setUp() + + def setup_course_and_user( + self, + days_till_start=1, + days_till_end=14, + days_till_upgrade_deadline=4, + enrollment_mode=CourseMode.VERIFIED, + days_till_verification_deadline=14, + verification_status=None, + ): + """Set up the course and user for this test.""" + now = datetime.now(pytz.UTC) + self.course = CourseFactory.create( # pylint: disable=attribute-defined-outside-init + start=now + timedelta(days=days_till_start), + end=now + timedelta(days=days_till_end), + ) + self.user = UserFactory.create() # pylint: disable=attribute-defined-outside-init + + if enrollment_mode is not None and days_till_upgrade_deadline is not None: + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=enrollment_mode, + expiration_datetime=now + timedelta(days=days_till_upgrade_deadline) + ) + CourseEnrollmentFactory.create(course_id=self.course.id, user=self.user, mode=enrollment_mode) + else: + CourseEnrollmentFactory.create(course_id=self.course.id, user=self.user) + + if days_till_verification_deadline is not None: + VerificationDeadline.objects.create( + course_key=self.course.id, + deadline=now + timedelta(days=days_till_verification_deadline) + ) + + if verification_status is not None: + SoftwareSecurePhotoVerificationFactory.create(user=self.user, status=verification_status) + + def test_course_info_feature_flag(self): + SelfPacedConfiguration(enable_course_home_improvements=False).save() + self.setup_course_and_user() + url = reverse('info', args=(self.course.id,)) + response = self.client.get(url) + self.assertNotIn('date-summary', response.content) + + # Tests for which blocks are enabled + + def assert_block_types(self, expected_blocks): + """Assert that the enabled block types for this course are as expected.""" + blocks = _get_course_date_summary_blocks(self.course, self.user) + self.assertEqual(len(blocks), len(expected_blocks)) + self.assertEqual(set(type(b) for b in blocks), set(expected_blocks)) + + @ddt.data( + # Before course starts + ({}, (CourseEndDate, CourseStartDate, TodaysDate, VerificationDeadlineDate, VerifiedUpgradeDeadlineDate)), + # After course end + ({'days_till_start': -10, + 'days_till_end': -5, + 'days_till_upgrade_deadline': -6, + 'days_till_verification_deadline': -5, + 'verification_status': 'approved'}, + (TodaysDate, CourseEndDate)), + # During course run + ({'days_till_start': -1}, + (TodaysDate, CourseEndDate, VerificationDeadlineDate, VerifiedUpgradeDeadlineDate)), + # Verification approved + ({'days_till_start': -10, + 'days_till_upgrade_deadline': -1, + 'days_till_verification_deadline': 1, + 'verification_status': 'approved'}, + (TodaysDate, CourseEndDate)), + # After upgrade deadline + ({'days_till_start': -10, 'days_till_upgrade_deadline': -1}, + (TodaysDate, CourseEndDate, VerificationDeadlineDate)), + # After verification deadline + ({'days_till_start': -10, + 'days_till_upgrade_deadline': -2, + 'days_till_verification_deadline': -1}, + (TodaysDate, CourseEndDate, VerificationDeadlineDate)) + ) + @ddt.unpack + def test_enabled_block_types(self, course_options, expected_blocks): + self.setup_course_and_user(**course_options) + self.assert_block_types(expected_blocks) + + # Specific block type tests + + ## Base DateSummary -- test empty defaults + + def test_date_summary(self): + self.setup_course_and_user() + block = DateSummary(self.course, self.user) + html = '
${description}
+ % endif + % if link and link_text: + + ${link_text} + + % endif +