Commit 986afbfa by Clinton Blackburn Committed by Clinton Blackburn

Powering courseware deadline with schedules

parent 85e4274a
...@@ -4,12 +4,15 @@ import datetime ...@@ -4,12 +4,15 @@ import datetime
import hashlib import hashlib
import ddt import ddt
import factory
import pytz import pytz
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache from django.core.cache import cache
from django.db.models import signals
from django.db.models.functions import Lower from django.db.models.functions import Lower
from course_modes.models import CourseMode from course_modes.models import CourseMode
from openedx.core.djangoapps.schedules.models import Schedule
from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -112,14 +115,18 @@ class CourseEnrollmentTests(SharedModuleStoreTestCase): ...@@ -112,14 +115,18 @@ class CourseEnrollmentTests(SharedModuleStoreTestCase):
self.assertListEqual([self.user, self.user_2], all_enrolled_users) self.assertListEqual([self.user, self.user_2], all_enrolled_users)
@skip_unless_lms @skip_unless_lms
# NOTE: We mute the post_save signal to prevent Schedules from being created for new enrollments
@factory.django.mute_signals(signals.post_save)
def test_upgrade_deadline(self): def test_upgrade_deadline(self):
""" The property should use either the CourseMode or related Schedule to determine the deadline. """ """ The property should use either the CourseMode or related Schedule to determine the deadline. """
course_mode = CourseModeFactory( course_mode = CourseModeFactory(
course_id=self.course.id, course_id=self.course.id,
mode_slug=CourseMode.VERIFIED, mode_slug=CourseMode.VERIFIED,
expiration_datetime=datetime.datetime.now(pytz.UTC) # This must be in the future to ensure it is returned by downstream code.
expiration_datetime=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1)
) )
enrollment = CourseEnrollmentFactory(course_id=self.course.id, mode=CourseMode.AUDIT) enrollment = CourseEnrollmentFactory(course_id=self.course.id, mode=CourseMode.AUDIT)
self.assertEqual(Schedule.objects.all().count(), 0)
self.assertEqual(enrollment.upgrade_deadline, course_mode.expiration_datetime) self.assertEqual(enrollment.upgrade_deadline, course_mode.expiration_datetime)
# The schedule's upgrade deadline should be used if a schedule exists # The schedule's upgrade deadline should be used if a schedule exists
......
...@@ -237,18 +237,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase): ...@@ -237,18 +237,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase):
# # of sql queries to default, # # of sql queries to default,
# # of mongo queries, # # of mongo queries,
# ) # )
('no_overrides', 1, True, False): (24, 1), ('no_overrides', 1, True, False): (25, 1),
('no_overrides', 2, True, False): (24, 1), ('no_overrides', 2, True, False): (25, 1),
('no_overrides', 3, True, False): (24, 1), ('no_overrides', 3, True, False): (25, 1),
('ccx', 1, True, False): (24, 1), ('ccx', 1, True, False): (25, 1),
('ccx', 2, True, False): (24, 1), ('ccx', 2, True, False): (25, 1),
('ccx', 3, True, False): (24, 1), ('ccx', 3, True, False): (25, 1),
('no_overrides', 1, False, False): (24, 1), ('no_overrides', 1, False, False): (25, 1),
('no_overrides', 2, False, False): (24, 1), ('no_overrides', 2, False, False): (25, 1),
('no_overrides', 3, False, False): (24, 1), ('no_overrides', 3, False, False): (25, 1),
('ccx', 1, False, False): (24, 1), ('ccx', 1, False, False): (25, 1),
('ccx', 2, False, False): (24, 1), ('ccx', 2, False, False): (25, 1),
('ccx', 3, False, False): (24, 1), ('ccx', 3, False, False): (25, 1),
} }
...@@ -260,19 +260,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase): ...@@ -260,19 +260,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase):
__test__ = True __test__ = True
TEST_DATA = { TEST_DATA = {
('no_overrides', 1, True, False): (24, 3), ('no_overrides', 1, True, False): (25, 3),
('no_overrides', 2, True, False): (24, 3), ('no_overrides', 2, True, False): (25, 3),
('no_overrides', 3, True, False): (24, 3), ('no_overrides', 3, True, False): (25, 3),
('ccx', 1, True, False): (24, 3), ('ccx', 1, True, False): (25, 3),
('ccx', 2, True, False): (24, 3), ('ccx', 2, True, False): (25, 3),
('ccx', 3, True, False): (24, 3), ('ccx', 3, True, False): (25, 3),
('ccx', 1, True, True): (25, 3), ('ccx', 1, True, True): (26, 3),
('ccx', 2, True, True): (25, 3), ('ccx', 2, True, True): (26, 3),
('ccx', 3, True, True): (25, 3), ('ccx', 3, True, True): (26, 3),
('no_overrides', 1, False, False): (24, 3), ('no_overrides', 1, False, False): (25, 3),
('no_overrides', 2, False, False): (24, 3), ('no_overrides', 2, False, False): (25, 3),
('no_overrides', 3, False, False): (24, 3), ('no_overrides', 3, False, False): (25, 3),
('ccx', 1, False, False): (24, 3), ('ccx', 1, False, False): (25, 3),
('ccx', 2, False, False): (24, 3), ('ccx', 2, False, False): (25, 3),
('ccx', 3, False, False): (24, 3), ('ccx', 3, False, False): (25, 3),
} }
...@@ -14,10 +14,8 @@ from lazy import lazy ...@@ -14,10 +14,8 @@ from lazy import lazy
from pytz import timezone, utc from pytz import timezone, utc
from course_modes.models import CourseMode from course_modes.models import CourseMode
from courseware.models import CourseDynamicUpgradeDeadlineConfiguration, DynamicUpgradeDeadlineConfiguration
from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -224,10 +222,6 @@ class VerifiedUpgradeDeadlineDate(DateSummary): ...@@ -224,10 +222,6 @@ class VerifiedUpgradeDeadlineDate(DateSummary):
def enrollment(self): def enrollment(self):
return CourseEnrollment.get_enrollment(self.user, self.course_id) return CourseEnrollment.get_enrollment(self.user, self.course_id)
@cached_property
def course_overview(self):
return CourseOverview.get_from_id(self.course_id)
@property @property
def is_enabled(self): def is_enabled(self):
""" """
...@@ -258,36 +252,8 @@ class VerifiedUpgradeDeadlineDate(DateSummary): ...@@ -258,36 +252,8 @@ class VerifiedUpgradeDeadlineDate(DateSummary):
def date(self): def date(self):
deadline = None deadline = None
try: if self.enrollment:
verified_mode = CourseMode.objects.get(course_id=self.course_id, mode_slug=CourseMode.VERIFIED) deadline = self.enrollment.upgrade_deadline
deadline = verified_mode.expiration_datetime
except CourseMode.DoesNotExist:
pass
if self.course and self.course_overview.self_paced and self.enrollment:
global_config = DynamicUpgradeDeadlineConfiguration.current()
if global_config.enabled:
delta = global_config.deadline_days
# Check if the given course has opted out of the feature
course_config = CourseDynamicUpgradeDeadlineConfiguration.current(self.course.id)
if course_config.enabled:
if course_config.opt_out:
return deadline
delta = course_config.deadline_days
# This represents the first date at which the learner can access the content. This will be the
# latter of either the enrollment date or the course's start date.
content_availability_date = max(self.enrollment.created, self.course_overview.start)
user_deadline = content_availability_date + datetime.timedelta(days=delta)
# If the deadline from above is None, make sure we have a value for comparison
deadline = deadline or datetime.date.max
# The user-specific deadline should never occur after the verified mode's expiration date,
# if one is set.
deadline = min(deadline, user_deadline)
return deadline return deadline
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
import ddt import ddt
import waffle
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from freezegun import freeze_time from freezegun import freeze_time
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
...@@ -34,6 +35,7 @@ from xmodule.modulestore.tests.factories import CourseFactory ...@@ -34,6 +35,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
@attr(shard=1) @attr(shard=1)
@ddt.ddt @ddt.ddt
@waffle.testutils.override_switch('schedules.enable-create-schedule-receiver', True)
class CourseDateSummaryTest(SharedModuleStoreTestCase): class CourseDateSummaryTest(SharedModuleStoreTestCase):
"""Tests for course date summary blocks.""" """Tests for course date summary blocks."""
...@@ -171,12 +173,12 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): ...@@ -171,12 +173,12 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
@ddt.data( @ddt.data(
# Course not started # Course not started
({}, (CourseStartDate, TodaysDate, CourseEndDate, VerifiedUpgradeDeadlineDate)), ({}, (CourseStartDate, TodaysDate, CourseEndDate)),
# Course active # Course active
({'days_till_start': -1}, (TodaysDate, CourseEndDate, VerifiedUpgradeDeadlineDate)), ({'days_till_start': -1}, (TodaysDate, CourseEndDate)),
# Course ended # Course ended
({'days_till_start': -10, 'days_till_end': -5}, ({'days_till_start': -10, 'days_till_end': -5},
(TodaysDate, CourseEndDate, VerifiedUpgradeDeadlineDate)), (TodaysDate, CourseEndDate)),
) )
@ddt.unpack @ddt.unpack
def test_enabled_block_types_without_enrollment(self, course_kwargs, expected_blocks): def test_enabled_block_types_without_enrollment(self, course_kwargs, expected_blocks):
......
...@@ -211,8 +211,8 @@ class IndexQueryTestCase(ModuleStoreTestCase): ...@@ -211,8 +211,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
NUM_PROBLEMS = 20 NUM_PROBLEMS = 20
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 10, 145), (ModuleStoreEnum.Type.mongo, 10, 146),
(ModuleStoreEnum.Type.split, 4, 145), (ModuleStoreEnum.Type.split, 4, 146),
) )
@ddt.unpack @ddt.unpack
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count): def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):
...@@ -1444,12 +1444,12 @@ class ProgressPageTests(ProgressPageBaseTests): ...@@ -1444,12 +1444,12 @@ class ProgressPageTests(ProgressPageBaseTests):
"""Test that query counts remain the same for self-paced and instructor-paced courses.""" """Test that query counts remain the same for self-paced and instructor-paced courses."""
SelfPacedConfiguration(enabled=self_paced_enabled).save() SelfPacedConfiguration(enabled=self_paced_enabled).save()
self.setup_course(self_paced=self_paced) self.setup_course(self_paced=self_paced)
with self.assertNumQueries(41, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1): with self.assertNumQueries(42, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1):
self._get_progress_page() self._get_progress_page()
@ddt.data( @ddt.data(
(False, 41, 27), (False, 42, 28),
(True, 34, 23) (True, 35, 24)
) )
@ddt.unpack @ddt.unpack
def test_progress_queries(self, enable_waffle, initial, subsequent): def test_progress_queries(self, enable_waffle, initial, subsequent):
......
...@@ -148,9 +148,9 @@ class RenderXBlockTestMixin(object): ...@@ -148,9 +148,9 @@ class RenderXBlockTestMixin(object):
return response return response
@ddt.data( @ddt.data(
('vertical_block', ModuleStoreEnum.Type.mongo, 14), ('vertical_block', ModuleStoreEnum.Type.mongo, 10),
('vertical_block', ModuleStoreEnum.Type.split, 6), ('vertical_block', ModuleStoreEnum.Type.split, 6),
('html_block', ModuleStoreEnum.Type.mongo, 15), ('html_block', ModuleStoreEnum.Type.mongo, 11),
('html_block', ModuleStoreEnum.Type.split, 6), ('html_block', ModuleStoreEnum.Type.split, 6),
) )
@ddt.unpack @ddt.unpack
......
default_app_config = 'openedx.core.djangoapps.schedules.apps.SchedulesConfig'
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _
class SchedulesConfig(AppConfig):
name = 'openedx.core.djangoapps.schedules'
verbose_name = _('Schedules')
def ready(self):
# noinspection PyUnresolvedReferences
from . import signals # pylint: disable=unused-variable
import datetime
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from course_modes.models import CourseMode
from courseware.models import DynamicUpgradeDeadlineConfiguration, CourseDynamicUpgradeDeadlineConfiguration
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from student.models import CourseEnrollment
from .models import Schedule
log = logging.getLogger(__name__)
def _get_upgrade_deadline(enrollment):
""" Returns the upgrade deadline for the given enrollment.
The deadline is determined based on the following data (in priority order):
1. Course run-specific deadline configuration (CourseDynamicUpgradeDeadlineConfiguration)
2. Global deadline configuration (DynamicUpgradeDeadlineConfiguration)
3. Verified course mode expiration
"""
course_key = enrollment.course_id
upgrade_deadline = None
try:
verified_mode = CourseMode.verified_mode_for_course(course_key)
if verified_mode:
upgrade_deadline = verified_mode.expiration_datetime
except CourseMode.DoesNotExist:
pass
global_config = DynamicUpgradeDeadlineConfiguration.current()
if global_config.enabled:
delta = global_config.deadline_days
# Check if the given course has opted out of the feature
course_config = CourseDynamicUpgradeDeadlineConfiguration.current(course_key)
if course_config.enabled:
if course_config.opt_out:
return upgrade_deadline
delta = course_config.deadline_days
course_overview = CourseOverview.get_from_id(course_key)
# This represents the first date at which the learner can access the content. This will be the latter of
# either the enrollment date or the course's start date.
content_availability_date = max(enrollment.created, course_overview.start)
cav_based_deadline = content_availability_date + datetime.timedelta(days=delta)
# If the deadline from above is None, make sure we have a value for comparison
upgrade_deadline = upgrade_deadline or datetime.date.max
# The content availability-based deadline should never occur after the verified mode's
# expiration date, if one is set.
upgrade_deadline = min(upgrade_deadline, cav_based_deadline)
return upgrade_deadline
@receiver(post_save, sender=CourseEnrollment, dispatch_uid='create_schedule_for_enrollment')
def create_schedule(sender, **kwargs):
if WaffleSwitchNamespace('schedules').is_enabled('enable-create-schedule-receiver') and kwargs['created']:
enrollment = kwargs['instance']
upgrade_deadline = _get_upgrade_deadline(enrollment)
Schedule.objects.create(enrollment=enrollment, start=timezone.now(), upgrade_deadline=upgrade_deadline)
from django.test import TestCase
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import CourseEnrollmentFactory
from ..models import Schedule
@skip_unless_lms
class CreateScheduleTests(TestCase):
def test_create_schedule(self):
""" A schedule should be created for every new enrollment if the switch is active. """
SWITCH_NAME = 'enable-create-schedule-receiver'
switch_namesapce = WaffleSwitchNamespace('schedules')
with switch_namesapce.override(SWITCH_NAME, True):
enrollment = CourseEnrollmentFactory()
self.assertIsNotNone(enrollment.schedule)
with switch_namesapce.override(SWITCH_NAME, False):
enrollment = CourseEnrollmentFactory()
with self.assertRaises(Schedule.DoesNotExist):
enrollment.schedule
...@@ -160,7 +160,7 @@ class TestCourseHomePage(CourseHomePageTestCase): ...@@ -160,7 +160,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
course_home_url(self.course) course_home_url(self.course)
# Fetch the view and verify the query counts # Fetch the view and verify the query counts
with self.assertNumQueries(41, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): with self.assertNumQueries(42, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4): with check_mongo_calls(4):
url = course_home_url(self.course) url = course_home_url(self.course)
self.client.get(url) self.client.get(url)
......
...@@ -127,7 +127,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase): ...@@ -127,7 +127,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
course_updates_url(self.course) course_updates_url(self.course)
# Fetch the view and verify that the query counts haven't changed # Fetch the view and verify that the query counts haven't changed
with self.assertNumQueries(32, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): with self.assertNumQueries(33, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4): with check_mongo_calls(4):
url = course_updates_url(self.course) url = course_updates_url(self.course)
self.client.get(url) self.client.get(url)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment