diff --git a/common/djangoapps/course_modes/admin.py b/common/djangoapps/course_modes/admin.py index ac3c9efaa4..cbf272bbd0 100644 --- a/common/djangoapps/course_modes/admin.py +++ b/common/djangoapps/course_modes/admin.py @@ -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) diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 393073e921..e0026329c0 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -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} diff --git a/common/djangoapps/course_modes/tests/test_admin.py b/common/djangoapps/course_modes/tests/test_admin.py new file mode 100644 index 0000000000..72242d14b2 --- /dev/null +++ b/common/djangoapps/course_modes/tests/test_admin.py @@ -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) diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index 11d2afc394..66197bf194 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -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)) diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py index e5678ca5cd..f5ce8a770a 100644 --- a/common/djangoapps/student/helpers.py +++ b/common/djangoapps/student/helpers.py @@ -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) diff --git a/common/djangoapps/student/tests/test_verification_status.py b/common/djangoapps/student/tests/test_verification_status.py index ff23e9401e..140b2f71d4 100644 --- a/common/djangoapps/student/tests/test_verification_status.py +++ b/common/djangoapps/student/tests/test_verification_status.py @@ -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", diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 60c1f08809..1dc601ad20 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -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 diff --git a/lms/djangoapps/verify_student/migrations/0011_add_verification_deadline.py b/lms/djangoapps/verify_student/migrations/0011_add_verification_deadline.py new file mode 100644 index 0000000000..2718a7df49 --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0011_add_verification_deadline.py @@ -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'] \ No newline at end of file diff --git a/lms/djangoapps/verify_student/migrations/0012_populate_verification_deadlines.py b/lms/djangoapps/verify_student/migrations/0012_populate_verification_deadlines.py new file mode 100644 index 0000000000..eaafcee52a --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0012_populate_verification_deadlines.py @@ -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 diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 32fb8b062a..e24f7bc659 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -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. diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index a365f1f5b4..f43fd4d187 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -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, {}) diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 60ebc36c86..73c1c958aa 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -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'], diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 74d993ebb2..3103f6b45a 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -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. """ diff --git a/lms/templates/verify_student/missed_deadline.html b/lms/templates/verify_student/missed_deadline.html new file mode 100644 index 0000000000..26192f2fd5 --- /dev/null +++ b/lms/templates/verify_student/missed_deadline.html @@ -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 name="content"> +
+

+ + % 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 +

+
+ diff --git a/lms/templates/verify_student/missed_verification_deadline.html b/lms/templates/verify_student/missed_verification_deadline.html deleted file mode 100644 index 5c69a3052d..0000000000 --- a/lms/templates/verify_student/missed_verification_deadline.html +++ /dev/null @@ -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 name="content"> -
-

${_( - u"The verification deadline for {course_name} was {date}. " - u"Verification is no longer available." - ).format( - course_name=course.display_name, - date=deadline - )}

-
-