Commit 25fa2ffc by Will Daly

Separate verification deadline from upgrade deadline

* Add verification deadline model.
* Populate verification deadlines from course modes table.
* Update student dashboard to use verification deadlines.
* Update pay-and-verify view to use verification deadlines.
* Simplify Django admin for course modes and add validation.
* Add verification deadline to Django admin for course modes.
* Add UI for when the upgrade deadline is missed in the pay-and-verify flow.
parent 80cf4d6e
...@@ -2,16 +2,31 @@ ...@@ -2,16 +2,31 @@
Django admin page for course modes Django admin page for course modes
""" """
from django.conf import settings from django.conf import settings
from pytz import timezone, UTC
from ratelimitbackend import admin
from course_modes.models import CourseMode
from django import forms from django import forms
from django.utils.translation import ugettext_lazy as _
from django.contrib import admin
from pytz import timezone, UTC
from opaque_keys import InvalidKeyError
from xmodule.modulestore.django import modulestore
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys import InvalidKeyError
from util.date_utils import get_time_display from util.date_utils import get_time_display
from xmodule.modulestore.django import modulestore
from course_modes.models import CourseMode
# Technically, we shouldn't be doing this, since verify_student is defined
# in LMS, and course_modes is defined in common.
#
# Once we move the responsibility for administering course modes into
# the Course Admin tool, we can remove this dependency and expose
# verification deadlines as a separate Django model admin.
#
# The admin page will work in both LMS and Studio,
# but the test suite for Studio will fail because
# the verification deadline table won't exist.
from verify_student import models as verification_models # pylint: disable=import-error
class CourseModeForm(forms.ModelForm): class CourseModeForm(forms.ModelForm):
...@@ -26,7 +41,45 @@ class CourseModeForm(forms.ModelForm): ...@@ -26,7 +41,45 @@ class CourseModeForm(forms.ModelForm):
[(mode_slug, mode_slug) for mode_slug in CourseMode.CREDIT_MODES] [(mode_slug, mode_slug) for mode_slug in CourseMode.CREDIT_MODES]
) )
mode_slug = forms.ChoiceField(choices=COURSE_MODE_SLUG_CHOICES) mode_slug = forms.ChoiceField(choices=COURSE_MODE_SLUG_CHOICES, label=_("Mode"))
# The verification deadline is stored outside the course mode in the verify_student app.
# (we used to use the course mode expiration_datetime as both an upgrade and verification deadline).
# In order to make this transition easier, we include the verification deadline as a custom field
# in the course mode admin form. Longer term, we will deprecate the course mode Django admin
# form in favor of an external Course Administration Tool.
verification_deadline = forms.SplitDateTimeField(
label=_("Verification Deadline"),
required=False,
help_text=_(
"OPTIONAL: After this date/time, users will no longer be able to submit photos for verification. "
"This appies ONLY to modes that require verification."
),
widget=admin.widgets.AdminSplitDateTime,
)
def __init__(self, *args, **kwargs):
super(CourseModeForm, self).__init__(*args, **kwargs)
default_tz = timezone(settings.TIME_ZONE)
if self.instance.expiration_datetime:
# django admin is using default timezone. To avoid time conversion from db to form
# convert the UTC object to naive and then localize with default timezone.
expiration_datetime = self.instance.expiration_datetime.replace(tzinfo=None)
self.initial["expiration_datetime"] = default_tz.localize(expiration_datetime)
# Load the verification deadline
# Since this is stored on a model in verify student, we need to load it from there.
# We need to munge the timezone a bit to get Django admin to display it without converting
# it to the user's timezone. We'll make sure we write it back to the database with the timezone
# set to UTC later.
if self.instance.course_id and self.instance.mode_slug in CourseMode.VERIFIED_MODES:
deadline = verification_models.VerificationDeadline.deadline_for_course(self.instance.course_id)
self.initial["verification_deadline"] = (
default_tz.localize(deadline.replace(tzinfo=None))
if deadline is not None else None
)
def clean_course_id(self): def clean_course_id(self):
course_id = self.cleaned_data['course_id'] course_id = self.cleaned_data['course_id']
...@@ -43,38 +96,111 @@ class CourseModeForm(forms.ModelForm): ...@@ -43,38 +96,111 @@ class CourseModeForm(forms.ModelForm):
return course_key return course_key
def __init__(self, *args, **kwargs):
super(CourseModeForm, self).__init__(*args, **kwargs)
if self.instance.expiration_datetime:
default_tz = timezone(settings.TIME_ZONE)
# django admin is using default timezone. To avoid time conversion from db to form
# convert the UTC object to naive and then localize with default timezone.
expiration_datetime = self.instance.expiration_datetime.replace(tzinfo=None)
self.initial['expiration_datetime'] = default_tz.localize(expiration_datetime)
def clean_expiration_datetime(self): def clean_expiration_datetime(self):
"""changing the tzinfo for a given datetime object""" """
Ensure that the expiration datetime we save uses the UTC timezone.
"""
# django admin saving the date with default timezone to avoid time conversion from form to db # django admin saving the date with default timezone to avoid time conversion from form to db
# changes its tzinfo to UTC # changes its tzinfo to UTC
if self.cleaned_data.get("expiration_datetime"): if self.cleaned_data.get("expiration_datetime"):
return self.cleaned_data.get("expiration_datetime").replace(tzinfo=UTC) return self.cleaned_data.get("expiration_datetime").replace(tzinfo=UTC)
def clean_verification_deadline(self):
"""
Ensure that the verification deadline we save uses the UTC timezone.
"""
if self.cleaned_data.get("verification_deadline"):
return self.cleaned_data.get("verification_deadline").replace(tzinfo=UTC)
def clean(self):
"""
Clean the form fields.
This is the place to perform checks that involve multiple form fields.
"""
cleaned_data = super(CourseModeForm, self).clean()
mode_slug = cleaned_data.get("mode_slug")
upgrade_deadline = cleaned_data.get("expiration_datetime")
verification_deadline = cleaned_data.get("verification_deadline")
# Allow upgrade deadlines ONLY for the "verified" mode
# This avoids a nasty error condition in which the upgrade deadline is set
# for a professional education course before the enrollment end date.
# When this happens, the course mode expires and students are able to enroll
# in the course for free. To avoid this, we explicitly prevent admins from
# setting an upgrade deadline for any mode except "verified" (which has an upgrade path).
if upgrade_deadline is not None and mode_slug != CourseMode.VERIFIED:
raise forms.ValidationError(
'Only the "verified" mode can have an upgrade deadline. '
'For other modes, please set the enrollment end date in Studio.'
)
# Verification deadlines are allowed only for verified modes
if verification_deadline is not None and mode_slug not in CourseMode.VERIFIED_MODES:
raise forms.ValidationError("Verification deadline can be set only for verified modes.")
# Verification deadline must be after the upgrade deadline,
# if an upgrade deadline is set.
# There are cases in which we might want to set a verification deadline,
# but not an upgrade deadline (for example, a professional education course that requires verification).
if verification_deadline is not None:
if upgrade_deadline is not None and verification_deadline < upgrade_deadline:
raise forms.ValidationError("Verification deadline must be after the upgrade deadline.")
return cleaned_data
def save(self, commit=True):
"""
Save the form data.
"""
# Trigger validation so we can access cleaned data
if self.is_valid():
course_key = self.cleaned_data.get("course_id")
verification_deadline = self.cleaned_data.get("verification_deadline")
mode_slug = self.cleaned_data.get("mode_slug")
# Since the verification deadline is stored in a separate model,
# we need to handle saving this ourselves.
# Note that verification deadline can be `None` here if
# the deadline is being disabled.
if course_key is not None and mode_slug in CourseMode.VERIFIED_MODES:
verification_models.VerificationDeadline.set_deadline(course_key, verification_deadline)
return super(CourseModeForm, self).save(commit=commit)
class CourseModeAdmin(admin.ModelAdmin): class CourseModeAdmin(admin.ModelAdmin):
"""Admin for course modes""" """Admin for course modes"""
form = CourseModeForm form = CourseModeForm
fields = (
'course_id',
'mode_slug',
'mode_display_name',
'min_price',
'currency',
'expiration_datetime',
'verification_deadline',
'sku'
)
search_fields = ('course_id',) search_fields = ('course_id',)
list_display = ( list_display = (
'id', 'course_id', 'mode_slug', 'mode_display_name', 'min_price', 'id',
'currency', 'expiration_date', 'expiration_datetime_custom', 'sku' 'course_id',
'mode_slug',
'min_price',
'expiration_datetime_custom',
'sku'
) )
exclude = ('suggested_prices',)
def expiration_datetime_custom(self, obj): def expiration_datetime_custom(self, obj):
"""adding custom column to show the expiry_datetime""" """adding custom column to show the expiry_datetime"""
if obj.expiration_datetime: if obj.expiration_datetime:
return get_time_display(obj.expiration_datetime, '%B %d, %Y, %H:%M %p') return get_time_display(obj.expiration_datetime, '%B %d, %Y, %H:%M %p')
expiration_datetime_custom.short_description = "Expiration Datetime" # Display a more user-friendly name for the custom expiration datetime field
# in the Django admin list view.
expiration_datetime_custom.short_description = "Upgrade Deadline"
admin.site.register(CourseMode, CourseModeAdmin) admin.site.register(CourseMode, CourseModeAdmin)
...@@ -30,28 +30,45 @@ class CourseMode(models.Model): ...@@ -30,28 +30,45 @@ class CourseMode(models.Model):
""" """
# the course that this mode is attached to # the course that this mode is attached to
course_id = CourseKeyField(max_length=255, db_index=True) course_id = CourseKeyField(max_length=255, db_index=True, verbose_name=_("Course"))
# the reference to this mode that can be used by Enrollments to generate # the reference to this mode that can be used by Enrollments to generate
# similar behavior for the same slug across courses # similar behavior for the same slug across courses
mode_slug = models.CharField(max_length=100) mode_slug = models.CharField(max_length=100, verbose_name=_("Mode"))
# The 'pretty' name that can be translated and displayed # The 'pretty' name that can be translated and displayed
mode_display_name = models.CharField(max_length=255) mode_display_name = models.CharField(max_length=255, verbose_name=_("Display Name"))
# minimum price in USD that we would like to charge for this mode of the course
min_price = models.IntegerField(default=0)
# the suggested prices for this mode # The price in USD that we would like to charge for this mode of the course
suggested_prices = models.CommaSeparatedIntegerField(max_length=255, blank=True, default='') # Historical note: We used to allow users to choose from several prices, but later
# switched to using a single price. Although this field is called `min_price`, it is
# really just the price of the course.
min_price = models.IntegerField(default=0, verbose_name=_("Price"))
# the currency these prices are in, using lower case ISO currency codes # the currency these prices are in, using lower case ISO currency codes
currency = models.CharField(default="usd", max_length=8) currency = models.CharField(default="usd", max_length=8)
# turn this mode off after the given expiration date # The datetime at which the course mode will expire.
# This is used to implement "upgrade" deadlines.
# For example, if there is a verified mode that expires on 1/1/2015,
# then users will be able to upgrade into the verified mode before that date.
# Once the date passes, users will no longer be able to enroll as verified.
expiration_datetime = models.DateTimeField(
default=None, null=True, blank=True,
verbose_name=_(u"Upgrade Deadline"),
help_text=_(
u"OPTIONAL: After this date/time, users will no longer be able to enroll in this mode. "
u"Leave this blank if users can enroll in this mode until enrollment closes for the course."
),
)
# DEPRECATED: the `expiration_date` field has been replaced by `expiration_datetime`
expiration_date = models.DateField(default=None, null=True, blank=True) expiration_date = models.DateField(default=None, null=True, blank=True)
expiration_datetime = models.DateTimeField(default=None, null=True, blank=True) # DEPRECATED: the suggested prices for this mode
# We used to allow users to choose from a set of prices, but we now allow only
# a single price. This field has been deprecated by `min_price`
suggested_prices = models.CommaSeparatedIntegerField(max_length=255, blank=True, default='')
# optional description override # optional description override
# WARNING: will not be localized # WARNING: will not be localized
...@@ -63,7 +80,10 @@ class CourseMode(models.Model): ...@@ -63,7 +80,10 @@ class CourseMode(models.Model):
null=True, null=True,
blank=True, blank=True,
verbose_name="SKU", verbose_name="SKU",
help_text="This is the SKU (stock keeping unit) of this mode in the external ecommerce service." help_text=_(
u"OPTIONAL: This is the SKU (stock keeping unit) of this mode in the external ecommerce service. "
u"Leave this blank if the course has not yet been migrated to the ecommerce service."
)
) )
HONOR = 'honor' HONOR = 'honor'
...@@ -217,7 +237,7 @@ class CourseMode(models.Model): ...@@ -217,7 +237,7 @@ class CourseMode(models.Model):
return modes return modes
@classmethod @classmethod
def modes_for_course_dict(cls, course_id, modes=None, only_selectable=True): def modes_for_course_dict(cls, course_id, modes=None, **kwargs):
"""Returns the non-expired modes for a particular course. """Returns the non-expired modes for a particular course.
Arguments: Arguments:
...@@ -228,6 +248,9 @@ class CourseMode(models.Model): ...@@ -228,6 +248,9 @@ class CourseMode(models.Model):
of course modes. This can be used to avoid an additional of course modes. This can be used to avoid an additional
database query if you have already loaded the modes list. database query if you have already loaded the modes list.
include_expired (bool): If True, expired course modes will be included
in the returned values. If False, these modes will be omitted.
only_selectable (bool): If True, include only modes that are shown only_selectable (bool): If True, include only modes that are shown
to users on the track selection page. (Currently, "credit" modes to users on the track selection page. (Currently, "credit" modes
aren't available to users until they complete the course, so aren't available to users until they complete the course, so
...@@ -238,7 +261,7 @@ class CourseMode(models.Model): ...@@ -238,7 +261,7 @@ class CourseMode(models.Model):
""" """
if modes is None: if modes is None:
modes = cls.modes_for_course(course_id, only_selectable=only_selectable) modes = cls.modes_for_course(course_id, **kwargs)
return {mode.slug: mode for mode in modes} return {mode.slug: mode for mode in modes}
......
"""
Tests for the course modes Django admin interface.
"""
import unittest
from datetime import datetime, timedelta
import ddt
from pytz import timezone, UTC
from django.conf import settings
from django.core.urlresolvers import reverse
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from util.date_utils import get_time_display
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory
from course_modes.models import CourseMode
from course_modes.admin import CourseModeForm
# Technically, we shouldn't be importing verify_student, since it's
# defined in the LMS and course_modes is in common. However, the benefits
# of putting all this configuration in one place outweigh the downsides.
# Once the course admin tool is deployed, we can remove this dependency.
from verify_student.models import VerificationDeadline # pylint: disable=import-error
# We can only test this in the LMS because the course modes admin relies
# on verify student, which is not an installed app in Studio, so the verification
# deadline table will not be created.
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class AdminCourseModePageTest(ModuleStoreTestCase):
"""
Test the course modes Django admin interface.
"""
def test_expiration_timezone(self):
# Test that expiration datetimes are saved and retrieved with the timezone set to UTC.
# This verifies the fix for a bug in which the date displayed to users was different
# than the date in Django admin.
user = UserFactory.create(is_staff=True, is_superuser=True)
user.save()
course = CourseFactory.create()
expiration = datetime(2015, 10, 20, 1, 10, 23, tzinfo=timezone(settings.TIME_ZONE))
data = {
'course_id': unicode(course.id),
'mode_slug': 'verified',
'mode_display_name': 'verified',
'min_price': 10,
'currency': 'usd',
'expiration_datetime_0': expiration.date(), # due to django admin datetime widget passing as seperate vals
'expiration_datetime_1': expiration.time(),
}
self.client.login(username=user.username, password='test')
# Create a new course mode from django admin page
response = self.client.post(reverse('admin:course_modes_coursemode_add'), data=data)
self.assertRedirects(response, reverse('admin:course_modes_coursemode_changelist'))
# Verify that datetime is appears on list page
response = self.client.get(reverse('admin:course_modes_coursemode_changelist'))
self.assertContains(response, get_time_display(expiration, '%B %d, %Y, %H:%M %p'))
# Verify that on the edit page the datetime value appears as UTC.
resp = self.client.get(reverse('admin:course_modes_coursemode_change', args=(1,)))
self.assertContains(resp, expiration.date())
self.assertContains(resp, expiration.time())
# Verify that the expiration datetime is the same as what we set
# (hasn't changed because of a timezone translation).
course_mode = CourseMode.objects.get(pk=1)
self.assertEqual(course_mode.expiration_datetime.replace(tzinfo=None), expiration.replace(tzinfo=None))
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@ddt.ddt
class AdminCourseModeFormTest(ModuleStoreTestCase):
"""
Test the course modes Django admin form validation and saving.
"""
UPGRADE_DEADLINE = datetime.now(UTC)
VERIFICATION_DEADLINE = UPGRADE_DEADLINE + timedelta(days=5)
def setUp(self):
"""
Create a test course.
"""
super(AdminCourseModeFormTest, self).setUp()
self.course = CourseFactory.create()
@ddt.data(
("honor", False),
("verified", True),
("professional", True),
("no-id-professional", False),
("credit", False),
)
@ddt.unpack
def test_load_verification_deadline(self, mode, expect_deadline):
# Configure a verification deadline for the course
VerificationDeadline.set_deadline(self.course.id, self.VERIFICATION_DEADLINE)
# Configure a course mode with both an upgrade and verification deadline
# and load the form to edit it.
deadline = self.UPGRADE_DEADLINE if mode == "verified" else None
form = self._admin_form(mode, upgrade_deadline=deadline)
# Check that the verification deadline is loaded,
# but ONLY for verified modes.
loaded_deadline = form.initial.get("verification_deadline")
if expect_deadline:
self.assertEqual(
loaded_deadline.replace(tzinfo=None),
self.VERIFICATION_DEADLINE.replace(tzinfo=None)
)
else:
self.assertIs(loaded_deadline, None)
@ddt.data("verified", "professional")
def test_set_verification_deadline(self, course_mode):
# Configure a verification deadline for the course
VerificationDeadline.set_deadline(self.course.id, self.VERIFICATION_DEADLINE)
# Create the course mode Django admin form
form = self._admin_form(course_mode)
# Update the verification deadline form data
# We need to set the date and time fields separately, since they're
# displayed as separate widgets in the form.
new_deadline = (self.VERIFICATION_DEADLINE + timedelta(days=1)).replace(microsecond=0)
self._set_form_verification_deadline(form, new_deadline)
form.save()
# Check that the deadline was updated
updated_deadline = VerificationDeadline.deadline_for_course(self.course.id)
self.assertEqual(updated_deadline, new_deadline)
def test_disable_verification_deadline(self):
# Configure a verification deadline for the course
VerificationDeadline.set_deadline(self.course.id, self.VERIFICATION_DEADLINE)
# Create the course mode Django admin form
form = self._admin_form("verified", upgrade_deadline=self.UPGRADE_DEADLINE)
# Use the form to disable the verification deadline
self._set_form_verification_deadline(form, None)
form.save()
# Check that the deadline was disabled
self.assertIs(VerificationDeadline.deadline_for_course(self.course.id), None)
@ddt.data("honor", "professional", "no-id-professional", "credit")
def test_validate_upgrade_deadline_only_for_verified(self, course_mode):
# Only the verified mode should have an upgrade deadline, so any other course
# mode that has an upgrade deadline set should cause a validation error
form = self._admin_form(course_mode, upgrade_deadline=self.UPGRADE_DEADLINE)
self._assert_form_has_error(form, (
'Only the "verified" mode can have an upgrade deadline. '
'For other modes, please set the enrollment end date in Studio.'
))
@ddt.data("honor", "no-id-professional", "credit")
def test_validate_verification_deadline_only_for_verified(self, course_mode):
# Only the verified mode should have a verification deadline set.
# Any other course mode should raise a validation error if a deadline is set.
form = self._admin_form(course_mode)
self._set_form_verification_deadline(form, self.VERIFICATION_DEADLINE)
self._assert_form_has_error(form, "Verification deadline can be set only for verified modes.")
def test_verification_deadline_after_upgrade_deadline(self):
form = self._admin_form("verified", upgrade_deadline=self.UPGRADE_DEADLINE)
before_upgrade = self.UPGRADE_DEADLINE - timedelta(days=1)
self._set_form_verification_deadline(form, before_upgrade)
self._assert_form_has_error(form, "Verification deadline must be after the upgrade deadline.")
def _configure(self, mode, upgrade_deadline=None, verification_deadline=None):
"""Configure course modes and deadlines. """
course_mode = CourseMode.objects.create(
mode_slug=mode,
mode_display_name=mode,
)
if upgrade_deadline is not None:
course_mode.upgrade_deadline = upgrade_deadline
course_mode.save()
VerificationDeadline.set_deadline(self.course.id, verification_deadline)
return CourseModeForm(instance=course_mode)
def _admin_form(self, mode, upgrade_deadline=None):
"""Load the course mode admin form. """
course_mode = CourseMode.objects.create(
course_id=self.course.id,
mode_slug=mode,
)
return CourseModeForm({
"course_id": unicode(self.course.id),
"mode_slug": mode,
"mode_display_name": mode,
"expiration_datetime": upgrade_deadline,
"currency": "usd",
"min_price": 10,
}, instance=course_mode)
def _set_form_verification_deadline(self, form, deadline):
"""Set the verification deadline on the course mode admin form. """
date_str = deadline.strftime("%Y-%m-%d") if deadline else None
time_str = deadline.strftime("%H:%M:%S") if deadline else None
form.data["verification_deadline_0"] = date_str
form.data["verification_deadline_1"] = time_str
def _assert_form_has_error(self, form, error):
"""Check that a form has a validation error. """
validation_errors = form.errors.get("__all__", [])
self.assertIn(error, validation_errors)
...@@ -4,12 +4,9 @@ import ddt ...@@ -4,12 +4,9 @@ import ddt
from mock import patch from mock import patch
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from pytz import timezone
from datetime import datetime
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from util.date_utils import get_time_display
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from embargo.test_utils import restrict_course from embargo.test_utils import restrict_course
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
...@@ -371,45 +368,3 @@ class TrackSelectionEmbargoTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -371,45 +368,3 @@ class TrackSelectionEmbargoTest(UrlResetMixin, ModuleStoreTestCase):
def test_embargo_allow(self): def test_embargo_allow(self):
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
class AdminCourseModePageTest(ModuleStoreTestCase):
"""Test the django admin course mode form saving data in db without any conversion
properly converts the tzinfo from default zone to utc
"""
def test_save_valid_data(self):
user = UserFactory.create(is_staff=True, is_superuser=True)
user.save()
course = CourseFactory.create()
expiration = datetime(2015, 10, 20, 1, 10, 23, tzinfo=timezone(settings.TIME_ZONE))
data = {
'course_id': unicode(course.id),
'mode_slug': 'professional',
'mode_display_name': 'professional',
'min_price': 10,
'currency': 'usd',
'expiration_datetime_0': expiration.date(), # due to django admin datetime widget passing as seperate vals
'expiration_datetime_1': expiration.time(),
}
self.client.login(username=user.username, password='test')
# creating new course mode from django admin page
response = self.client.post(reverse('admin:course_modes_coursemode_add'), data=data)
self.assertRedirects(response, reverse('admin:course_modes_coursemode_changelist'))
# verifying that datetime is appearing on list page
response = self.client.get(reverse('admin:course_modes_coursemode_changelist'))
self.assertContains(response, get_time_display(expiration, '%B %d, %Y, %H:%M %p'))
# verifiying the on edit page datetime value appearing without any modifications
resp = self.client.get(reverse('admin:course_modes_coursemode_change', args=(1,)))
self.assertContains(resp, expiration.date())
self.assertContains(resp, expiration.time())
# checking the values in db. comparing values without tzinfo
course_mode = CourseMode.objects.get(pk=1)
self.assertEqual(course_mode.expiration_datetime.replace(tzinfo=None), expiration.replace(tzinfo=None))
...@@ -6,7 +6,7 @@ from pytz import UTC ...@@ -6,7 +6,7 @@ from pytz import UTC
from django.core.urlresolvers import reverse, NoReverseMatch from django.core.urlresolvers import reverse, NoReverseMatch
import third_party_auth import third_party_auth
from verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error from verify_student.models import VerificationDeadline, SoftwareSecurePhotoVerification # pylint: disable=import-error
from course_modes.models import CourseMode from course_modes.models import CourseMode
...@@ -19,7 +19,7 @@ VERIFY_STATUS_MISSED_DEADLINE = "verify_missed_deadline" ...@@ -19,7 +19,7 @@ VERIFY_STATUS_MISSED_DEADLINE = "verify_missed_deadline"
VERIFY_STATUS_NEED_TO_REVERIFY = "verify_need_to_reverify" VERIFY_STATUS_NEED_TO_REVERIFY = "verify_need_to_reverify"
def check_verify_status_by_course(user, course_enrollments, all_course_modes): def check_verify_status_by_course(user, course_enrollments):
""" """
Determine the per-course verification statuses for a given user. Determine the per-course verification statuses for a given user.
...@@ -44,8 +44,6 @@ def check_verify_status_by_course(user, course_enrollments, all_course_modes): ...@@ -44,8 +44,6 @@ def check_verify_status_by_course(user, course_enrollments, all_course_modes):
Arguments: Arguments:
user (User): The currently logged-in user. user (User): The currently logged-in user.
course_enrollments (list[CourseEnrollment]): The courses the user is enrolled in. course_enrollments (list[CourseEnrollment]): The courses the user is enrolled in.
all_course_modes (list): List of all course modes for the student's enrolled courses,
including modes that have expired.
Returns: Returns:
dict: Mapping of course keys verification status dictionaries. dict: Mapping of course keys verification status dictionaries.
...@@ -69,24 +67,21 @@ def check_verify_status_by_course(user, course_enrollments, all_course_modes): ...@@ -69,24 +67,21 @@ def check_verify_status_by_course(user, course_enrollments, all_course_modes):
user, queryset=verifications user, queryset=verifications
) )
# Retrieve verification deadlines for the enrolled courses
enrolled_course_keys = [enrollment.course_id for enrollment in course_enrollments]
course_deadlines = VerificationDeadline.deadlines_for_courses(enrolled_course_keys)
recent_verification_datetime = None recent_verification_datetime = None
for enrollment in course_enrollments: for enrollment in course_enrollments:
# Get the verified mode (if any) for this course # If the user hasn't enrolled as verified, then the course
# We pass in the course modes we have already loaded to avoid # won't display state related to its verification status.
# another database hit, as well as to ensure that expired if enrollment.mode in CourseMode.VERIFIED_MODES:
# course modes are included in the search.
verified_mode = CourseMode.verified_mode_for_course( # Retrieve the verification deadline associated with the course.
enrollment.course_id, # This could be None if the course doesn't have a deadline.
modes=all_course_modes[enrollment.course_id] deadline = course_deadlines.get(enrollment.course_id)
)
# If no verified mode has ever been offered, or the user hasn't enrolled
# as verified, then the course won't display state related to its
# verification status.
if verified_mode is not None and enrollment.mode in CourseMode.VERIFIED_MODES:
deadline = verified_mode.expiration_datetime
relevant_verification = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, verifications) relevant_verification = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, verifications)
......
...@@ -20,7 +20,7 @@ from xmodule.modulestore.tests.factories import CourseFactory ...@@ -20,7 +20,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from course_modes.tests.factories import CourseModeFactory from course_modes.tests.factories import CourseModeFactory
from verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error from verify_student.models import VerificationDeadline, SoftwareSecurePhotoVerification # pylint: disable=import-error
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
...@@ -61,9 +61,11 @@ class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase): ...@@ -61,9 +61,11 @@ class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase):
mode="verified" mode="verified"
) )
# The default course has no verified mode, # Continue to show the student as needing to verify.
# so no verification status should be displayed # The student is enrolled as verified, so we might as well let them
self._assert_course_verification_status(None) # complete verification. We'd need to change their enrollment mode
# anyway to ensure that the student is issued the correct kind of certificate.
self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_VERIFY)
def test_need_to_verify_no_expiration(self): def test_need_to_verify_no_expiration(self):
self._setup_mode_and_enrollment(None, "verified") self._setup_mode_and_enrollment(None, "verified")
...@@ -285,6 +287,7 @@ class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase): ...@@ -285,6 +287,7 @@ class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase):
user=self.user, user=self.user,
mode=enrollment_mode mode=enrollment_mode
) )
VerificationDeadline.set_deadline(self.course.id, deadline)
BANNER_ALT_MESSAGES = { BANNER_ALT_MESSAGES = {
None: "Honor", None: "Honor",
......
...@@ -533,7 +533,7 @@ def dashboard(request): ...@@ -533,7 +533,7 @@ def dashboard(request):
# Retrieve the course modes for each course # Retrieve the course modes for each course
enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments] enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments]
all_course_modes, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids) __, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids)
course_modes_by_course = { course_modes_by_course = {
course_id: { course_id: {
mode.slug: mode mode.slug: mode
...@@ -596,11 +596,7 @@ def dashboard(request): ...@@ -596,11 +596,7 @@ def dashboard(request):
# #
# If a course is not included in this dictionary, # If a course is not included in this dictionary,
# there is no verification messaging to display. # there is no verification messaging to display.
verify_status_by_course = check_verify_status_by_course( verify_status_by_course = check_verify_status_by_course(user, course_enrollments)
user,
course_enrollments,
all_course_modes
)
cert_statuses = { cert_statuses = {
enrollment.course_id: cert_info(request.user, enrollment.course_overview, enrollment.mode) enrollment.course_id: cert_info(request.user, enrollment.course_overview, enrollment.mode)
for enrollment in course_enrollments for enrollment in course_enrollments
......
...@@ -24,14 +24,17 @@ from django.conf import settings ...@@ -24,14 +24,17 @@ from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.cache import cache
from django.dispatch import receiver
from django.db import models from django.db import models
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _, ugettext_lazy
from boto.s3.connection import S3Connection from boto.s3.connection import S3Connection
from boto.s3.key import Key from boto.s3.key import Key
from simple_history.models import HistoricalRecords
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
from course_modes.models import CourseMode from course_modes.models import CourseMode
from model_utils.models import StatusModel from model_utils.models import StatusModel, TimeStampedModel
from model_utils import Choices from model_utils import Choices
from verify_student.ssencrypt import ( from verify_student.ssencrypt import (
random_aes_key, encrypt_and_encode, random_aes_key, encrypt_and_encode,
...@@ -884,6 +887,119 @@ class SoftwareSecurePhotoVerification(PhotoVerification): ...@@ -884,6 +887,119 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
return 'ID Verified' return 'ID Verified'
class VerificationDeadline(TimeStampedModel):
"""
Represent a verification deadline for a particular course.
The verification deadline is the datetime after which
users are no longer allowed to submit photos for initial verification
in a course.
Note that this is NOT the same as the "upgrade" deadline, after
which a user is no longer allowed to upgrade to a verified enrollment.
If no verification deadline record exists for a course,
then that course does not have a deadline. This means that users
can submit photos at any time.
"""
course_key = CourseKeyField(
max_length=255,
db_index=True,
unique=True,
help_text=ugettext_lazy(u"The course for which this deadline applies"),
)
deadline = models.DateTimeField(
help_text=ugettext_lazy(
u"The datetime after which users are no longer allowed "
u"to submit photos for verification."
)
)
# Maintain a history of changes to deadlines for auditing purposes
history = HistoricalRecords()
ALL_DEADLINES_CACHE_KEY = "verify_student.all_verification_deadlines"
@classmethod
def set_deadline(cls, course_key, deadline):
"""
Configure the verification deadline for a course.
If `deadline` is `None`, then the course will have no verification
deadline. In this case, users will be able to verify for the course
at any time.
Arguments:
course_key (CourseKey): Identifier for the course.
deadline (datetime or None): The verification deadline.
"""
if deadline is None:
VerificationDeadline.objects.filter(course_key=course_key).delete()
else:
record, created = VerificationDeadline.objects.get_or_create(
course_key=course_key,
defaults={"deadline": deadline}
)
if not created:
record.deadline = deadline
record.save()
@classmethod
def deadlines_for_courses(cls, course_keys):
"""
Retrieve verification deadlines for particular courses.
Arguments:
course_keys (list): List of `CourseKey`s.
Returns:
dict: Map of course keys to datetimes (verification deadlines)
"""
all_deadlines = cache.get(cls.ALL_DEADLINES_CACHE_KEY)
if all_deadlines is None:
all_deadlines = {
deadline.course_key: deadline.deadline
for deadline in VerificationDeadline.objects.all()
}
cache.set(cls.ALL_DEADLINES_CACHE_KEY, all_deadlines)
return {
course_key: all_deadlines[course_key]
for course_key in course_keys
if course_key in all_deadlines
}
@classmethod
def deadline_for_course(cls, course_key):
"""
Retrieve the verification deadline for a particular course.
Arguments:
course_key (CourseKey): The identifier for the course.
Returns:
datetime or None
"""
try:
deadline = cls.objects.get(course_key=course_key)
return deadline.deadline
except cls.DoesNotExist:
return None
@receiver(models.signals.post_save, sender=VerificationDeadline)
@receiver(models.signals.post_delete, sender=VerificationDeadline)
def invalidate_deadline_caches(sender, **kwargs): # pylint: disable=unused-argument
"""Invalidate the cached verification deadline information. """
cache.delete(VerificationDeadline.ALL_DEADLINES_CACHE_KEY)
class VerificationCheckpoint(models.Model): class VerificationCheckpoint(models.Model):
"""Represents a point at which a user is asked to re-verify his/her """Represents a point at which a user is asked to re-verify his/her
identity. identity.
......
...@@ -7,6 +7,7 @@ import pytz ...@@ -7,6 +7,7 @@ import pytz
from django.conf import settings from django.conf import settings
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.test import TestCase
from mock import patch from mock import patch
from nose.tools import assert_is_none, assert_equals, assert_raises, assert_true, assert_false # pylint: disable=no-name-in-module from nose.tools import assert_is_none, assert_equals, assert_raises, assert_true, assert_false # pylint: disable=no-name-in-module
...@@ -14,9 +15,13 @@ from student.tests.factories import UserFactory ...@@ -14,9 +15,13 @@ from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from opaque_keys.edx.keys import CourseKey
from verify_student.models import ( from verify_student.models import (
SoftwareSecurePhotoVerification, VerificationException, VerificationCheckpoint, VerificationStatus, SoftwareSecurePhotoVerification,
SkippedReverification VerificationException, VerificationCheckpoint,
VerificationStatus, SkippedReverification,
VerificationDeadline
) )
FAKE_SETTINGS = { FAKE_SETTINGS = {
...@@ -763,3 +768,45 @@ class SkippedReverificationTest(ModuleStoreTestCase): ...@@ -763,3 +768,45 @@ class SkippedReverificationTest(ModuleStoreTestCase):
self.assertFalse( self.assertFalse(
SkippedReverification.check_user_skipped_reverification_exists(course_id=self.course.id, user=user2) SkippedReverification.check_user_skipped_reverification_exists(course_id=self.course.id, user=user2)
) )
class VerificationDeadlineTest(TestCase):
"""
Tests for the VerificationDeadline model.
"""
def test_caching(self):
deadlines = {
CourseKey.from_string("edX/DemoX/Fall"): datetime.now(pytz.UTC),
CourseKey.from_string("edX/DemoX/Spring"): datetime.now(pytz.UTC) + timedelta(days=1)
}
course_keys = deadlines.keys()
# Initially, no deadlines are set
with self.assertNumQueries(1):
all_deadlines = VerificationDeadline.deadlines_for_courses(course_keys)
self.assertEqual(all_deadlines, {})
# Create the deadlines
for course_key, deadline in deadlines.iteritems():
VerificationDeadline.objects.create(
course_key=course_key,
deadline=deadline,
)
# Warm the cache
with self.assertNumQueries(1):
VerificationDeadline.deadlines_for_courses(course_keys)
# Load the deadlines from the cache
with self.assertNumQueries(0):
all_deadlines = VerificationDeadline.deadlines_for_courses(course_keys)
self.assertEqual(all_deadlines, deadlines)
# Delete the deadlines
VerificationDeadline.objects.all().delete()
# Verify that the deadlines are updated correctly
with self.assertNumQueries(1):
all_deadlines = VerificationDeadline.deadlines_for_courses(course_keys)
self.assertEqual(all_deadlines, {})
...@@ -43,8 +43,9 @@ from verify_student.views import ( ...@@ -43,8 +43,9 @@ from verify_student.views import (
_compose_message_reverification_email _compose_message_reverification_email
) )
from verify_student.models import ( from verify_student.models import (
SoftwareSecurePhotoVerification, VerificationCheckpoint, VerificationDeadline, SoftwareSecurePhotoVerification,
InCourseReverificationConfiguration, VerificationStatus VerificationCheckpoint, InCourseReverificationConfiguration,
VerificationStatus
) )
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
...@@ -612,15 +613,15 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase): ...@@ -612,15 +613,15 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
self._assert_contribution_amount(response, "12.34") self._assert_contribution_amount(response, "12.34")
def test_verification_deadline(self): def test_verification_deadline(self):
# Set a deadline on the course mode deadline = datetime(2999, 1, 2, tzinfo=pytz.UTC)
course = self._create_course("verified") course = self._create_course("verified")
mode = CourseMode.objects.get(
course_id=course.id, # Set a deadline on the course mode AND on the verification deadline model.
mode_slug="verified" # This simulates the common case in which the upgrade deadline (course mode expiration)
) # and the verification deadline are the same.
expiration = datetime(2999, 1, 2, tzinfo=pytz.UTC) # NOTE: we used to use the course mode expiration datetime for BOTH of these deadlines,
mode.expiration_datetime = expiration # before the VerificationDeadline model was introduced.
mode.save() self._set_deadlines(course.id, upgrade_deadline=deadline, verification_deadline=deadline)
# Expect that the expiration date is set # Expect that the expiration date is set
response = self._get_page("verify_student_start_flow", course.id) response = self._get_page("verify_student_start_flow", course.id)
...@@ -628,14 +629,13 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase): ...@@ -628,14 +629,13 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
self.assertEqual(data['verification_deadline'], "Jan 02, 2999 at 00:00 UTC") self.assertEqual(data['verification_deadline'], "Jan 02, 2999 at 00:00 UTC")
def test_course_mode_expired(self): def test_course_mode_expired(self):
deadline = datetime(1999, 1, 2, tzinfo=pytz.UTC)
course = self._create_course("verified") course = self._create_course("verified")
mode = CourseMode.objects.get(
course_id=course.id, # Set the upgrade deadline (course mode expiration) and verification deadline
mode_slug="verified" # to the same value. This used to be the default when we used the expiration datetime
) # for BOTH values.
expiration = datetime(1999, 1, 2, tzinfo=pytz.UTC) self._set_deadlines(course.id, upgrade_deadline=deadline, verification_deadline=deadline)
mode.expiration_datetime = expiration
mode.save()
# Need to be enrolled # Need to be enrolled
self._enroll(course.id, "verified") self._enroll(course.id, "verified")
...@@ -646,6 +646,66 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase): ...@@ -646,6 +646,66 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
self.assertContains(response, "verification deadline") self.assertContains(response, "verification deadline")
self.assertContains(response, "Jan 02, 1999 at 00:00 UTC") self.assertContains(response, "Jan 02, 1999 at 00:00 UTC")
@ddt.data(datetime(2999, 1, 2, tzinfo=pytz.UTC), None)
def test_course_mode_expired_verification_deadline_in_future(self, verification_deadline):
course = self._create_course("verified")
# Set the upgrade deadline in the past, but the verification
# deadline in the future.
self._set_deadlines(
course.id,
upgrade_deadline=datetime(1999, 1, 2, tzinfo=pytz.UTC),
verification_deadline=verification_deadline,
)
# Try to pay or upgrade.
# We should get an error message since the deadline has passed.
for page_name in ["verify_student_start_flow", "verify_student_upgrade_and_verify"]:
response = self._get_page(page_name, course.id)
self.assertContains(response, "Upgrade Deadline Has Passed")
# Simulate paying for the course and enrolling
self._enroll(course.id, "verified")
# Enter the verification part of the flow
# Expect that we are able to verify
response = self._get_page("verify_student_verify_now", course.id)
self.assertNotContains(response, "Verification is no longer available")
data = self._get_page_data(response)
self.assertEqual(data['message_key'], PayAndVerifyView.VERIFY_NOW_MSG)
# Check that the verification deadline (rather than the upgrade deadline) is displayed
if verification_deadline is not None:
self.assertEqual(data["verification_deadline"], "Jan 02, 2999 at 00:00 UTC")
else:
self.assertEqual(data["verification_deadline"], "")
def test_course_mode_not_expired_verification_deadline_passed(self):
course = self._create_course("verified")
# Set the upgrade deadline in the future
# and the verification deadline in the past
# We try not to discourage this with validation rules,
# since it's a bad user experience
# to purchase a verified track and then not be able to verify,
# but if it happens we need to handle it gracefully.
self._set_deadlines(
course.id,
upgrade_deadline=datetime(2999, 1, 2, tzinfo=pytz.UTC),
verification_deadline=datetime(1999, 1, 2, tzinfo=pytz.UTC),
)
# Enroll as verified (simulate purchasing the verified enrollment)
self._enroll(course.id, "verified")
# Even though the upgrade deadline is in the future,
# the verification deadline has passed, so we should see an error
# message when we go to verify.
response = self._get_page("verify_student_verify_now", course.id)
self.assertContains(response, "verification deadline")
self.assertContains(response, "Jan 02, 1999 at 00:00 UTC")
@mock.patch.dict(settings.FEATURES, {'EMBARGO': True}) @mock.patch.dict(settings.FEATURES, {'EMBARGO': True})
def test_embargo_restrict(self): def test_embargo_restrict(self):
course = self._create_course("verified") course = self._create_course("verified")
...@@ -716,6 +776,30 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase): ...@@ -716,6 +776,30 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
attempt.created_at = datetime.now(pytz.UTC) - timedelta(days=(days_good_for + 1)) attempt.created_at = datetime.now(pytz.UTC) - timedelta(days=(days_good_for + 1))
attempt.save() attempt.save()
def _set_deadlines(self, course_key, upgrade_deadline=None, verification_deadline=None):
"""
Set the upgrade and verification deadlines.
Arguments:
course_key (CourseKey): Identifier for the course.
Keyword Arguments:
upgrade_deadline (datetime): Datetime after which a user cannot
upgrade to a verified mode.
verification_deadline (datetime): Datetime after which a user cannot
submit an initial verification attempt.
"""
# Set the course mode expiration (same as the "upgrade" deadline)
mode = CourseMode.objects.get(course_id=course_key, mode_slug="verified")
mode.expiration_datetime = upgrade_deadline
mode.save()
# Set the verification deadline
VerificationDeadline.set_deadline(course_key, verification_deadline)
def _set_contribution(self, amount, course_id): def _set_contribution(self, amount, course_id):
"""Set the contribution amount pre-filled in a session var. """ """Set the contribution amount pre-filled in a session var. """
session = self.client.session session = self.client.session
...@@ -785,6 +869,15 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase): ...@@ -785,6 +869,15 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
"""Retrieve the data attributes rendered on the page. """ """Retrieve the data attributes rendered on the page. """
soup = BeautifulSoup(response.content) soup = BeautifulSoup(response.content)
pay_and_verify_div = soup.find(id="pay-and-verify-container") pay_and_verify_div = soup.find(id="pay-and-verify-container")
self.assertIsNot(
pay_and_verify_div, None,
msg=(
"Could not load pay and verify flow data. "
"Maybe this isn't the pay and verify page?"
)
)
return { return {
'full_name': pay_and_verify_div['data-full-name'], 'full_name': pay_and_verify_div['data-full-name'],
'course_key': pay_and_verify_div['data-course-key'], 'course_key': pay_and_verify_div['data-course-key'],
......
<%!
from django.utils.translation import ugettext as _
from verify_student.views import PayAndVerifyView
%>
<%namespace name='static' file='../static_content.html'/>
<%inherit file="../main.html" />
<%block name="pagetitle">
% if deadline_name == PayAndVerifyView.VERIFICATION_DEADLINE:
${_("Verification Deadline Has Passed")}
% elif deadline_name == PayAndVerifyView.UPGRADE_DEADLINE:
${_("Upgrade Deadline Has Passed")}
% endif
</%block>
<%block name="content">
<section class="outside-app">
<p>
% if deadline_name == PayAndVerifyView.VERIFICATION_DEADLINE:
${_(u"The verification deadline for {course_name} was {date}. Verification is no longer available.").format(
course_name=course.display_name, date=deadline)}
% elif deadline_name == PayAndVerifyView.UPGRADE_DEADLINE:
${_(u"The deadline to upgrade to a verified certificate for this course has passed. You can still earn an honor code certificate.")}
% endif
</p>
</section>
</%block>
<%! from django.utils.translation import ugettext as _ %>
<%namespace name='static' file='../static_content.html'/>
<%inherit file="../main.html" />
<%block name="pagetitle">${_("Verification Deadline Has Passed")}</%block>
<%block name="content">
<section class="outside-app">
<p>${_(
u"The verification deadline for {course_name} was {date}. "
u"Verification is no longer available."
).format(
course_name=course.display_name,
date=deadline
)}</p>
</section>
</%block>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment