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.
This commit is contained in:
@@ -2,16 +2,31 @@
|
||||
Django admin page for course modes
|
||||
"""
|
||||
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.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.locations import SlashSeparatedCourseKey
|
||||
from opaque_keys import InvalidKeyError
|
||||
|
||||
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):
|
||||
@@ -26,7 +41,45 @@ class CourseModeForm(forms.ModelForm):
|
||||
[(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):
|
||||
course_id = self.cleaned_data['course_id']
|
||||
@@ -43,38 +96,111 @@ class CourseModeForm(forms.ModelForm):
|
||||
|
||||
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):
|
||||
"""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
|
||||
# changes its tzinfo to UTC
|
||||
if self.cleaned_data.get("expiration_datetime"):
|
||||
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):
|
||||
"""Admin for course modes"""
|
||||
form = CourseModeForm
|
||||
search_fields = ('course_id',)
|
||||
list_display = (
|
||||
'id', 'course_id', 'mode_slug', 'mode_display_name', 'min_price',
|
||||
'currency', 'expiration_date', 'expiration_datetime_custom', 'sku'
|
||||
|
||||
fields = (
|
||||
'course_id',
|
||||
'mode_slug',
|
||||
'mode_display_name',
|
||||
'min_price',
|
||||
'currency',
|
||||
'expiration_datetime',
|
||||
'verification_deadline',
|
||||
'sku'
|
||||
)
|
||||
|
||||
search_fields = ('course_id',)
|
||||
|
||||
list_display = (
|
||||
'id',
|
||||
'course_id',
|
||||
'mode_slug',
|
||||
'min_price',
|
||||
'expiration_datetime_custom',
|
||||
'sku'
|
||||
)
|
||||
exclude = ('suggested_prices',)
|
||||
|
||||
def expiration_datetime_custom(self, obj):
|
||||
"""adding custom column to show the expiry_datetime"""
|
||||
if obj.expiration_datetime:
|
||||
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)
|
||||
|
||||
@@ -30,28 +30,45 @@ class CourseMode(models.Model):
|
||||
|
||||
"""
|
||||
# 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
|
||||
# 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
|
||||
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
|
||||
suggested_prices = models.CommaSeparatedIntegerField(max_length=255, blank=True, default='')
|
||||
# The price in USD that we would like to charge for this mode of the course
|
||||
# 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
|
||||
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_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
|
||||
# WARNING: will not be localized
|
||||
@@ -63,7 +80,10 @@ class CourseMode(models.Model):
|
||||
null=True,
|
||||
blank=True,
|
||||
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'
|
||||
@@ -217,7 +237,7 @@ class CourseMode(models.Model):
|
||||
return modes
|
||||
|
||||
@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.
|
||||
|
||||
Arguments:
|
||||
@@ -228,6 +248,9 @@ class CourseMode(models.Model):
|
||||
of course modes. This can be used to avoid an additional
|
||||
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
|
||||
to users on the track selection page. (Currently, "credit" modes
|
||||
aren't available to users until they complete the course, so
|
||||
@@ -238,7 +261,7 @@ class CourseMode(models.Model):
|
||||
|
||||
"""
|
||||
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}
|
||||
|
||||
|
||||
220
common/djangoapps/course_modes/tests/test_admin.py
Normal file
220
common/djangoapps/course_modes/tests/test_admin.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
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
|
||||
from mock import patch
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from pytz import timezone
|
||||
from datetime import datetime
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
from util.date_utils import get_time_display
|
||||
from util.testing import UrlResetMixin
|
||||
from embargo.test_utils import restrict_course
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
@@ -371,45 +368,3 @@ class TrackSelectionEmbargoTest(UrlResetMixin, ModuleStoreTestCase):
|
||||
def test_embargo_allow(self):
|
||||
response = self.client.get(self.url)
|
||||
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
|
||||
from django.core.urlresolvers import reverse, NoReverseMatch
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ VERIFY_STATUS_MISSED_DEADLINE = "verify_missed_deadline"
|
||||
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.
|
||||
|
||||
@@ -44,8 +44,6 @@ def check_verify_status_by_course(user, course_enrollments, all_course_modes):
|
||||
Arguments:
|
||||
user (User): The currently logged-in user.
|
||||
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:
|
||||
dict: Mapping of course keys verification status dictionaries.
|
||||
@@ -69,24 +67,21 @@ def check_verify_status_by_course(user, course_enrollments, all_course_modes):
|
||||
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
|
||||
|
||||
for enrollment in course_enrollments:
|
||||
|
||||
# Get the verified mode (if any) for this course
|
||||
# We pass in the course modes we have already loaded to avoid
|
||||
# another database hit, as well as to ensure that expired
|
||||
# course modes are included in the search.
|
||||
verified_mode = CourseMode.verified_mode_for_course(
|
||||
enrollment.course_id,
|
||||
modes=all_course_modes[enrollment.course_id]
|
||||
)
|
||||
# If the user hasn't enrolled as verified, then the course
|
||||
# won't display state related to its verification status.
|
||||
if enrollment.mode in CourseMode.VERIFIED_MODES:
|
||||
|
||||
# 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
|
||||
# Retrieve the verification deadline associated with the course.
|
||||
# This could be None if the course doesn't have a deadline.
|
||||
deadline = course_deadlines.get(enrollment.course_id)
|
||||
|
||||
relevant_verification = SoftwareSecurePhotoVerification.verification_for_datetime(deadline, verifications)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
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
|
||||
|
||||
|
||||
@@ -61,9 +61,11 @@ class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase):
|
||||
mode="verified"
|
||||
)
|
||||
|
||||
# The default course has no verified mode,
|
||||
# so no verification status should be displayed
|
||||
self._assert_course_verification_status(None)
|
||||
# Continue to show the student as needing to verify.
|
||||
# The student is enrolled as verified, so we might as well let them
|
||||
# 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):
|
||||
self._setup_mode_and_enrollment(None, "verified")
|
||||
@@ -285,6 +287,7 @@ class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase):
|
||||
user=self.user,
|
||||
mode=enrollment_mode
|
||||
)
|
||||
VerificationDeadline.set_deadline(self.course.id, deadline)
|
||||
|
||||
BANNER_ALT_MESSAGES = {
|
||||
None: "Honor",
|
||||
|
||||
@@ -533,7 +533,7 @@ def dashboard(request):
|
||||
|
||||
# Retrieve the course modes for each course
|
||||
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_id: {
|
||||
mode.slug: mode
|
||||
@@ -596,11 +596,7 @@ def dashboard(request):
|
||||
#
|
||||
# If a course is not included in this dictionary,
|
||||
# there is no verification messaging to display.
|
||||
verify_status_by_course = check_verify_status_by_course(
|
||||
user,
|
||||
course_enrollments,
|
||||
all_course_modes
|
||||
)
|
||||
verify_status_by_course = check_verify_status_by_course(user, course_enrollments)
|
||||
cert_statuses = {
|
||||
enrollment.course_id: cert_info(request.user, enrollment.course_overview, enrollment.mode)
|
||||
for enrollment in course_enrollments
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.utils import datetime_utils as datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'HistoricalVerificationDeadline'
|
||||
db.create_table('verify_student_historicalverificationdeadline', (
|
||||
('id', self.gf('django.db.models.fields.IntegerField')(db_index=True, blank=True)),
|
||||
('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)),
|
||||
('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)),
|
||||
('course_key', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
|
||||
('deadline', self.gf('django.db.models.fields.DateTimeField')()),
|
||||
(u'history_id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
(u'history_date', self.gf('django.db.models.fields.DateTimeField')()),
|
||||
(u'history_user', self.gf('django.db.models.fields.related.ForeignKey')(related_name=u'+', null=True, on_delete=models.SET_NULL, to=orm['auth.User'])),
|
||||
(u'history_type', self.gf('django.db.models.fields.CharField')(max_length=1)),
|
||||
))
|
||||
db.send_create_signal('verify_student', ['HistoricalVerificationDeadline'])
|
||||
|
||||
# Adding model 'VerificationDeadline'
|
||||
db.create_table('verify_student_verificationdeadline', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('created', self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now)),
|
||||
('modified', self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now)),
|
||||
('course_key', self.gf('xmodule_django.models.CourseKeyField')(unique=True, max_length=255, db_index=True)),
|
||||
('deadline', self.gf('django.db.models.fields.DateTimeField')()),
|
||||
))
|
||||
db.send_create_signal('verify_student', ['VerificationDeadline'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'HistoricalVerificationDeadline'
|
||||
db.delete_table('verify_student_historicalverificationdeadline')
|
||||
|
||||
# Deleting model 'VerificationDeadline'
|
||||
db.delete_table('verify_student_verificationdeadline')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'verify_student.historicalverificationdeadline': {
|
||||
'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalVerificationDeadline'},
|
||||
'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'deadline': ('django.db.models.fields.DateTimeField', [], {}),
|
||||
u'history_date': ('django.db.models.fields.DateTimeField', [], {}),
|
||||
u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
|
||||
u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
|
||||
'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'})
|
||||
},
|
||||
'verify_student.incoursereverificationconfiguration': {
|
||||
'Meta': {'ordering': "('-change_date',)", 'object_name': 'InCourseReverificationConfiguration'},
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'verify_student.skippedreverification': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'SkippedReverification'},
|
||||
'checkpoint': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'skipped_checkpoint'", 'to': "orm['verify_student.VerificationCheckpoint']"}),
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'verify_student.softwaresecurephotoverification': {
|
||||
'Meta': {'ordering': "['-created_at']", 'object_name': 'SoftwareSecurePhotoVerification'},
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'display': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'db_index': 'True'}),
|
||||
'error_code': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
|
||||
'error_msg': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'face_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'photo_id_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'photo_id_key': ('django.db.models.fields.TextField', [], {'max_length': '1024'}),
|
||||
'receipt_id': ('django.db.models.fields.CharField', [], {'default': "'9b14470c-4219-4c69-9a38-d8ff14b60b09'", 'max_length': '255', 'db_index': 'True'}),
|
||||
'reviewing_service': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'reviewing_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'photo_verifications_reviewed'", 'null': 'True', 'to': "orm['auth.User']"}),
|
||||
'status': ('model_utils.fields.StatusField', [], {'default': "'created'", 'max_length': '100', u'no_check_for_status': 'True'}),
|
||||
'status_changed': ('model_utils.fields.MonitorField', [], {'default': 'datetime.datetime.now', u'monitor': "u'status'"}),
|
||||
'submitted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'verify_student.verificationcheckpoint': {
|
||||
'Meta': {'unique_together': "(('course_id', 'checkpoint_location'),)", 'object_name': 'VerificationCheckpoint'},
|
||||
'checkpoint_location': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'photo_verification': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['verify_student.SoftwareSecurePhotoVerification']", 'symmetrical': 'False'})
|
||||
},
|
||||
'verify_student.verificationdeadline': {
|
||||
'Meta': {'object_name': 'VerificationDeadline'},
|
||||
'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'deadline': ('django.db.models.fields.DateTimeField', [], {}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'})
|
||||
},
|
||||
'verify_student.verificationstatus': {
|
||||
'Meta': {'object_name': 'VerificationStatus'},
|
||||
'checkpoint': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'checkpoint_status'", 'to': "orm['verify_student.VerificationCheckpoint']"}),
|
||||
'error': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'response': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'timestamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['verify_student']
|
||||
@@ -0,0 +1,180 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.utils import datetime_utils as datetime
|
||||
from south.db import db
|
||||
from south.v2 import DataMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(DataMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
"""
|
||||
This migration populates the "verification deadline" model with
|
||||
the "expiration datetime" from the course modes table for verified
|
||||
courses.
|
||||
|
||||
In the past, the course modes expiration (really an upgrade deadline)
|
||||
and the verification deadline were always set to the same value.
|
||||
With this change, the verification deadline will now be tracked in a separate
|
||||
model owned by the verify_student app.
|
||||
"""
|
||||
|
||||
# Retrieve all verified course modes (whether they have expired or not)
|
||||
# Unfortunately, we don't have access to constants from the application here,
|
||||
# so we hard-code the names of the course modes that require verification.
|
||||
verified_modes = orm['course_modes.CourseMode'].objects.filter(
|
||||
mode_slug__in=["verified", "professional"],
|
||||
expiration_datetime__isnull=False,
|
||||
)
|
||||
|
||||
for mode in verified_modes:
|
||||
orm.VerificationDeadline.objects.create(
|
||||
course_key=mode.course_id,
|
||||
deadline=mode.expiration_datetime,
|
||||
)
|
||||
|
||||
def backwards(self, orm):
|
||||
"""
|
||||
Backwards migration deletes all verification deadlines.
|
||||
"""
|
||||
orm.VerificationDeadline.objects.all().delete()
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'course_modes.coursemode': {
|
||||
'Meta': {'unique_together': "(('course_id', 'mode_slug', 'currency'),)", 'object_name': 'CourseMode'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
|
||||
'description': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'expiration_date': ('django.db.models.fields.DateField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
|
||||
'expiration_datetime': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'min_price': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'mode_display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'sku': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
|
||||
'suggested_prices': ('django.db.models.fields.CommaSeparatedIntegerField', [], {'default': "''", 'max_length': '255', 'blank': 'True'})
|
||||
},
|
||||
'course_modes.coursemodesarchive': {
|
||||
'Meta': {'object_name': 'CourseModesArchive'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}),
|
||||
'expiration_date': ('django.db.models.fields.DateField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
|
||||
'expiration_datetime': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'min_price': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'mode_display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'suggested_prices': ('django.db.models.fields.CommaSeparatedIntegerField', [], {'default': "''", 'max_length': '255', 'blank': 'True'})
|
||||
},
|
||||
'verify_student.historicalverificationdeadline': {
|
||||
'Meta': {'ordering': "(u'-history_date', u'-history_id')", 'object_name': 'HistoricalVerificationDeadline'},
|
||||
'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'deadline': ('django.db.models.fields.DateTimeField', [], {}),
|
||||
u'history_date': ('django.db.models.fields.DateTimeField', [], {}),
|
||||
u'history_id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
u'history_type': ('django.db.models.fields.CharField', [], {'max_length': '1'}),
|
||||
u'history_user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['auth.User']"}),
|
||||
'id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'blank': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'})
|
||||
},
|
||||
'verify_student.incoursereverificationconfiguration': {
|
||||
'Meta': {'ordering': "('-change_date',)", 'object_name': 'InCourseReverificationConfiguration'},
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'verify_student.skippedreverification': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'SkippedReverification'},
|
||||
'checkpoint': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'skipped_checkpoint'", 'to': "orm['verify_student.VerificationCheckpoint']"}),
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'verify_student.softwaresecurephotoverification': {
|
||||
'Meta': {'ordering': "['-created_at']", 'object_name': 'SoftwareSecurePhotoVerification'},
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'display': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'db_index': 'True'}),
|
||||
'error_code': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
|
||||
'error_msg': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'face_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'photo_id_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'photo_id_key': ('django.db.models.fields.TextField', [], {'max_length': '1024'}),
|
||||
'receipt_id': ('django.db.models.fields.CharField', [], {'default': "'6644e0c2-da9b-49a4-9d0c-c19c596c911e'", 'max_length': '255', 'db_index': 'True'}),
|
||||
'reviewing_service': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'reviewing_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'photo_verifications_reviewed'", 'null': 'True', 'to': "orm['auth.User']"}),
|
||||
'status': ('model_utils.fields.StatusField', [], {'default': "'created'", 'max_length': '100', u'no_check_for_status': 'True'}),
|
||||
'status_changed': ('model_utils.fields.MonitorField', [], {'default': 'datetime.datetime.now', u'monitor': "u'status'"}),
|
||||
'submitted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'verify_student.verificationcheckpoint': {
|
||||
'Meta': {'unique_together': "(('course_id', 'checkpoint_location'),)", 'object_name': 'VerificationCheckpoint'},
|
||||
'checkpoint_location': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'photo_verification': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['verify_student.SoftwareSecurePhotoVerification']", 'symmetrical': 'False'})
|
||||
},
|
||||
'verify_student.verificationdeadline': {
|
||||
'Meta': {'object_name': 'VerificationDeadline'},
|
||||
'course_key': ('xmodule_django.models.CourseKeyField', [], {'unique': 'True', 'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'deadline': ('django.db.models.fields.DateTimeField', [], {}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'})
|
||||
},
|
||||
'verify_student.verificationstatus': {
|
||||
'Meta': {'object_name': 'VerificationStatus'},
|
||||
'checkpoint': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'checkpoint_status'", 'to': "orm['verify_student.VerificationCheckpoint']"}),
|
||||
'error': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'response': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'timestamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['course_modes', 'verify_student']
|
||||
symmetrical = True
|
||||
@@ -24,14 +24,17 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.cache import cache
|
||||
from django.dispatch import receiver
|
||||
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.key import Key
|
||||
from simple_history.models import HistoricalRecords
|
||||
from config_models.models import ConfigurationModel
|
||||
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 verify_student.ssencrypt import (
|
||||
random_aes_key, encrypt_and_encode,
|
||||
@@ -884,6 +887,119 @@ class SoftwareSecurePhotoVerification(PhotoVerification):
|
||||
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):
|
||||
"""Represents a point at which a user is asked to re-verify his/her
|
||||
identity.
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytz
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.utils import IntegrityError
|
||||
from django.test import TestCase
|
||||
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
|
||||
|
||||
@@ -14,9 +15,13 @@ from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from verify_student.models import (
|
||||
SoftwareSecurePhotoVerification, VerificationException, VerificationCheckpoint, VerificationStatus,
|
||||
SkippedReverification
|
||||
SoftwareSecurePhotoVerification,
|
||||
VerificationException, VerificationCheckpoint,
|
||||
VerificationStatus, SkippedReverification,
|
||||
VerificationDeadline
|
||||
)
|
||||
|
||||
FAKE_SETTINGS = {
|
||||
@@ -763,3 +768,45 @@ class SkippedReverificationTest(ModuleStoreTestCase):
|
||||
self.assertFalse(
|
||||
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 (
|
||||
_compose_message_reverification_email
|
||||
)
|
||||
from verify_student.models import (
|
||||
SoftwareSecurePhotoVerification, VerificationCheckpoint,
|
||||
InCourseReverificationConfiguration, VerificationStatus
|
||||
VerificationDeadline, SoftwareSecurePhotoVerification,
|
||||
VerificationCheckpoint, InCourseReverificationConfiguration,
|
||||
VerificationStatus
|
||||
)
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
@@ -612,15 +613,15 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
|
||||
self._assert_contribution_amount(response, "12.34")
|
||||
|
||||
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")
|
||||
mode = CourseMode.objects.get(
|
||||
course_id=course.id,
|
||||
mode_slug="verified"
|
||||
)
|
||||
expiration = datetime(2999, 1, 2, tzinfo=pytz.UTC)
|
||||
mode.expiration_datetime = expiration
|
||||
mode.save()
|
||||
|
||||
# Set a deadline on the course mode AND on the verification deadline model.
|
||||
# This simulates the common case in which the upgrade deadline (course mode expiration)
|
||||
# and the verification deadline are the same.
|
||||
# NOTE: we used to use the course mode expiration datetime for BOTH of these deadlines,
|
||||
# before the VerificationDeadline model was introduced.
|
||||
self._set_deadlines(course.id, upgrade_deadline=deadline, verification_deadline=deadline)
|
||||
|
||||
# Expect that the expiration date is set
|
||||
response = self._get_page("verify_student_start_flow", course.id)
|
||||
@@ -628,14 +629,13 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
|
||||
self.assertEqual(data['verification_deadline'], "Jan 02, 2999 at 00:00 UTC")
|
||||
|
||||
def test_course_mode_expired(self):
|
||||
deadline = datetime(1999, 1, 2, tzinfo=pytz.UTC)
|
||||
course = self._create_course("verified")
|
||||
mode = CourseMode.objects.get(
|
||||
course_id=course.id,
|
||||
mode_slug="verified"
|
||||
)
|
||||
expiration = datetime(1999, 1, 2, tzinfo=pytz.UTC)
|
||||
mode.expiration_datetime = expiration
|
||||
mode.save()
|
||||
|
||||
# Set the upgrade deadline (course mode expiration) and verification deadline
|
||||
# to the same value. This used to be the default when we used the expiration datetime
|
||||
# for BOTH values.
|
||||
self._set_deadlines(course.id, upgrade_deadline=deadline, verification_deadline=deadline)
|
||||
|
||||
# Need to be enrolled
|
||||
self._enroll(course.id, "verified")
|
||||
@@ -646,6 +646,66 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
|
||||
self.assertContains(response, "verification deadline")
|
||||
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})
|
||||
def test_embargo_restrict(self):
|
||||
course = self._create_course("verified")
|
||||
@@ -716,6 +776,30 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
|
||||
attempt.created_at = datetime.now(pytz.UTC) - timedelta(days=(days_good_for + 1))
|
||||
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):
|
||||
"""Set the contribution amount pre-filled in a session var. """
|
||||
session = self.client.session
|
||||
@@ -785,6 +869,15 @@ class TestPayAndVerifyView(UrlResetMixin, ModuleStoreTestCase):
|
||||
"""Retrieve the data attributes rendered on the page. """
|
||||
soup = BeautifulSoup(response.content)
|
||||
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 {
|
||||
'full_name': pay_and_verify_div['data-full-name'],
|
||||
'course_key': pay_and_verify_div['data-course-key'],
|
||||
|
||||
@@ -47,6 +47,7 @@ from shoppingcart.processors import (
|
||||
)
|
||||
from verify_student.ssencrypt import has_valid_signature
|
||||
from verify_student.models import (
|
||||
VerificationDeadline,
|
||||
SoftwareSecurePhotoVerification,
|
||||
VerificationCheckpoint,
|
||||
VerificationStatus,
|
||||
@@ -194,6 +195,10 @@ class PayAndVerifyView(View):
|
||||
FACE_PHOTO_STEP: [WEBCAM_REQ],
|
||||
}
|
||||
|
||||
# Deadline types
|
||||
VERIFICATION_DEADLINE = "verification"
|
||||
UPGRADE_DEADLINE = "upgrade"
|
||||
|
||||
@method_decorator(login_required)
|
||||
def get(
|
||||
self, request, course_id,
|
||||
@@ -201,7 +206,8 @@ class PayAndVerifyView(View):
|
||||
current_step=None,
|
||||
message=FIRST_TIME_VERIFY_MSG
|
||||
):
|
||||
"""Render the pay/verify requirements page.
|
||||
"""
|
||||
Render the payment and verification flow.
|
||||
|
||||
Arguments:
|
||||
request (HttpRequest): The request object.
|
||||
@@ -244,28 +250,45 @@ class PayAndVerifyView(View):
|
||||
if redirect_url:
|
||||
return redirect(redirect_url)
|
||||
|
||||
expired_verified_course_mode, unexpired_paid_course_mode = self._get_expired_verified_and_paid_mode(course_key)
|
||||
# If the verification deadline has passed
|
||||
# then show the user a message that he/she can't verify.
|
||||
#
|
||||
# We're making the assumptions (enforced in Django admin) that:
|
||||
#
|
||||
# 1) Only verified modes have verification deadlines.
|
||||
#
|
||||
# 2) If set, verification deadlines are always AFTER upgrade deadlines, because why would you
|
||||
# let someone upgrade into a verified track if they can't complete verification?
|
||||
#
|
||||
verification_deadline = VerificationDeadline.deadline_for_course(course.id)
|
||||
response = self._response_if_deadline_passed(course, self.VERIFICATION_DEADLINE, verification_deadline)
|
||||
if response is not None:
|
||||
log.info(u"Verification deadline for '%s' has passed.", course.id)
|
||||
return response
|
||||
|
||||
# Check that the course has an unexpired paid mode
|
||||
if unexpired_paid_course_mode is not None:
|
||||
if CourseMode.is_verified_mode(unexpired_paid_course_mode):
|
||||
# Retrieve the relevant course mode for the payment/verification flow.
|
||||
#
|
||||
# WARNING: this is technical debt! A much better way to do this would be to
|
||||
# separate out the payment flow and use the product SKU to figure out what
|
||||
# the user is trying to purchase.
|
||||
#
|
||||
# Nonethless, for the time being we continue to make the really ugly assumption
|
||||
# that at some point there was a paid course mode we can query for the price.
|
||||
relevant_course_mode = self._get_paid_mode(course_key)
|
||||
|
||||
# If we can find a relevant course mode, then log that we're entering the flow
|
||||
# Otherwise, this course does not support payment/verification, so respond with a 404.
|
||||
if relevant_course_mode is not None:
|
||||
if CourseMode.is_verified_mode(relevant_course_mode):
|
||||
log.info(
|
||||
u"Entering verified workflow for user '%s', course '%s', with current step '%s'.",
|
||||
u"Entering payment and verification flow for user '%s', course '%s', with current step '%s'.",
|
||||
request.user.id, course_id, current_step
|
||||
)
|
||||
elif expired_verified_course_mode is not None:
|
||||
# Check if there is an *expired* verified course mode;
|
||||
# if so, we should show a message explaining that the verification
|
||||
# deadline has passed.
|
||||
log.info(u"Verification deadline for '%s' has passed.", course_id)
|
||||
context = {
|
||||
'course': course,
|
||||
'deadline': (
|
||||
get_default_time_display(expired_verified_course_mode.expiration_datetime)
|
||||
if expired_verified_course_mode.expiration_datetime else ""
|
||||
else:
|
||||
log.info(
|
||||
u"Entering payment flow for user '%s', course '%s', with current step '%s'",
|
||||
request.user.id, course_id, current_step
|
||||
)
|
||||
}
|
||||
return render_to_response("verify_student/missed_verification_deadline.html", context)
|
||||
else:
|
||||
# Otherwise, there has never been a verified/paid mode,
|
||||
# so return a page not found response.
|
||||
@@ -275,14 +298,32 @@ class PayAndVerifyView(View):
|
||||
)
|
||||
raise Http404
|
||||
|
||||
# If the user is trying to *pay* and the upgrade deadline has passed,
|
||||
# then they shouldn't be able to enter the flow.
|
||||
#
|
||||
# NOTE: This should match the availability dates used by the E-Commerce service
|
||||
# to determine whether a user can purchase a product. The idea is that if the service
|
||||
# won't fulfill the order, we shouldn't even let the user get into the payment flow.
|
||||
#
|
||||
user_is_trying_to_pay = message in [self.FIRST_TIME_VERIFY_MSG, self.UPGRADE_MSG]
|
||||
if user_is_trying_to_pay:
|
||||
upgrade_deadline = relevant_course_mode.expiration_datetime
|
||||
response = self._response_if_deadline_passed(course, self.UPGRADE_DEADLINE, upgrade_deadline)
|
||||
if response is not None:
|
||||
log.info(u"Upgrade deadline for '%s' has passed.", course.id)
|
||||
return response
|
||||
|
||||
# Check whether the user has verified, paid, and enrolled.
|
||||
# A user is considered "paid" if he or she has an enrollment
|
||||
# with a paid course mode (such as "verified").
|
||||
# For this reason, every paid user is enrolled, but not
|
||||
# every enrolled user is paid.
|
||||
# If the course mode is not verified(i.e only paid) then already_verified is always True
|
||||
already_verified = self._check_already_verified(request.user) \
|
||||
if CourseMode.is_verified_mode(unexpired_paid_course_mode) else True
|
||||
already_verified = (
|
||||
self._check_already_verified(request.user)
|
||||
if CourseMode.is_verified_mode(relevant_course_mode)
|
||||
else True
|
||||
)
|
||||
already_paid, is_enrolled = self._check_enrollment(request.user, course_key)
|
||||
|
||||
# Redirect the user to a more appropriate page if the
|
||||
@@ -302,7 +343,7 @@ class PayAndVerifyView(View):
|
||||
always_show_payment,
|
||||
already_verified,
|
||||
already_paid,
|
||||
unexpired_paid_course_mode
|
||||
relevant_course_mode
|
||||
)
|
||||
requirements = self._requirements(display_steps, request.user.is_active)
|
||||
|
||||
@@ -346,7 +387,7 @@ class PayAndVerifyView(View):
|
||||
verification_good_until = self._verification_valid_until(request.user)
|
||||
|
||||
# get available payment processors
|
||||
if unexpired_paid_course_mode.sku:
|
||||
if relevant_course_mode.sku:
|
||||
# transaction will be conducted via ecommerce service
|
||||
processors = ecommerce_api_client(request.user).payment.processors.get()
|
||||
else:
|
||||
@@ -358,7 +399,7 @@ class PayAndVerifyView(View):
|
||||
'contribution_amount': contribution_amount,
|
||||
'course': course,
|
||||
'course_key': unicode(course_key),
|
||||
'course_mode': unexpired_paid_course_mode,
|
||||
'course_mode': relevant_course_mode,
|
||||
'courseware_url': courseware_url,
|
||||
'current_step': current_step,
|
||||
'disable_courseware_js': True,
|
||||
@@ -370,8 +411,8 @@ class PayAndVerifyView(View):
|
||||
'requirements': requirements,
|
||||
'user_full_name': full_name,
|
||||
'verification_deadline': (
|
||||
get_default_time_display(unexpired_paid_course_mode.expiration_datetime)
|
||||
if unexpired_paid_course_mode.expiration_datetime else ""
|
||||
get_default_time_display(verification_deadline)
|
||||
if verification_deadline else ""
|
||||
),
|
||||
'already_verified': already_verified,
|
||||
'verification_good_until': verification_good_until,
|
||||
@@ -449,41 +490,35 @@ class PayAndVerifyView(View):
|
||||
if url is not None:
|
||||
return redirect(url)
|
||||
|
||||
def _get_expired_verified_and_paid_mode(self, course_key): # pylint: disable=invalid-name
|
||||
"""Retrieve expired verified mode and unexpired paid mode(with min_price>0) for a course.
|
||||
def _get_paid_mode(self, course_key):
|
||||
"""
|
||||
Retrieve the paid course mode for a course.
|
||||
|
||||
The returned course mode may or may not be expired.
|
||||
Unexpired modes are preferred to expired modes.
|
||||
|
||||
Arguments:
|
||||
course_key (CourseKey): The location of the course.
|
||||
|
||||
Returns:
|
||||
Tuple of `(expired_verified_mode, unexpired_paid_mode)`. If provided,
|
||||
`expired_verified_mode` is an *expired* verified mode for the course.
|
||||
If provided, `unexpired_paid_mode` is an *unexpired* paid(with min_price>0)
|
||||
mode for the course. Either of these may be None.
|
||||
CourseMode tuple
|
||||
|
||||
"""
|
||||
# Retrieve all the modes at once to reduce the number of database queries
|
||||
all_modes, unexpired_modes = CourseMode.all_and_unexpired_modes_for_courses([course_key])
|
||||
|
||||
# Unexpired paid modes
|
||||
unexpired_paid_modes = [mode for mode in unexpired_modes[course_key] if mode.min_price]
|
||||
if len(unexpired_paid_modes) > 1:
|
||||
# There is more than one paid mode defined,
|
||||
# so choose the first one.
|
||||
log.warn(
|
||||
u"More than one paid modes are defined for course '%s' choosing the first one %s",
|
||||
course_key, unexpired_paid_modes[0]
|
||||
)
|
||||
unexpired_paid_mode = unexpired_paid_modes[0] if unexpired_paid_modes else None
|
||||
# Retrieve the first unexpired, paid mode, if there is one
|
||||
for mode in unexpired_modes[course_key]:
|
||||
if mode.min_price > 0:
|
||||
return mode
|
||||
|
||||
# Find an unexpired verified mode
|
||||
verified_mode = CourseMode.verified_mode_for_course(course_key, modes=unexpired_modes[course_key])
|
||||
expired_verified_mode = None
|
||||
# Otherwise, find the first expired mode
|
||||
for mode in all_modes[course_key]:
|
||||
if mode.min_price > 0:
|
||||
return mode
|
||||
|
||||
if verified_mode is None:
|
||||
expired_verified_mode = CourseMode.verified_mode_for_course(course_key, modes=all_modes[course_key])
|
||||
|
||||
return (expired_verified_mode, unexpired_paid_mode)
|
||||
# Otherwise, return None and so the view knows to respond with a 404.
|
||||
return None
|
||||
|
||||
def _display_steps(self, always_show_payment, already_verified, already_paid, course_mode):
|
||||
"""Determine which steps to display to the user.
|
||||
@@ -610,12 +645,43 @@ class PayAndVerifyView(View):
|
||||
has_paid = False
|
||||
|
||||
if enrollment_mode is not None and is_active:
|
||||
all_modes = CourseMode.modes_for_course_dict(course_key)
|
||||
all_modes = CourseMode.modes_for_course_dict(course_key, include_expired=True)
|
||||
course_mode = all_modes.get(enrollment_mode)
|
||||
has_paid = (course_mode and course_mode.min_price > 0)
|
||||
|
||||
return (has_paid, bool(is_active))
|
||||
|
||||
def _response_if_deadline_passed(self, course, deadline_name, deadline_datetime):
|
||||
"""
|
||||
Respond with some error messaging if the deadline has passed.
|
||||
|
||||
Arguments:
|
||||
course (Course): The course the user is trying to enroll in.
|
||||
deadline_name (str): One of the deadline constants.
|
||||
deadline_datetime (datetime): The deadline.
|
||||
|
||||
Returns: HttpResponse or None
|
||||
|
||||
"""
|
||||
if deadline_name not in [self.VERIFICATION_DEADLINE, self.UPGRADE_DEADLINE]:
|
||||
log.error("Invalid deadline name %s. Skipping check for whether the deadline passed.", deadline_name)
|
||||
return None
|
||||
|
||||
deadline_passed = (
|
||||
deadline_datetime is not None and
|
||||
deadline_datetime < datetime.datetime.now(UTC)
|
||||
)
|
||||
if deadline_passed:
|
||||
context = {
|
||||
'course': course,
|
||||
'deadline_name': deadline_name,
|
||||
'deadline': (
|
||||
get_default_time_display(deadline_datetime)
|
||||
if deadline_datetime else ""
|
||||
)
|
||||
}
|
||||
return render_to_response("verify_student/missed_deadline.html", context)
|
||||
|
||||
|
||||
def checkout_with_ecommerce_service(user, course_key, course_mode, processor): # pylint: disable=invalid-name
|
||||
""" Create a new basket and trigger immediate checkout, using the E-Commerce API. """
|
||||
|
||||
29
lms/templates/verify_student/missed_deadline.html
Normal file
29
lms/templates/verify_student/missed_deadline.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<%!
|
||||
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>
|
||||
@@ -1,18 +0,0 @@
|
||||
<%! 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>
|
||||
Reference in New Issue
Block a user