Powering courseware deadline with schedules
This commit is contained in:
committed by
Clinton Blackburn
parent
85e4274a05
commit
986afbfa38
@@ -4,12 +4,15 @@ import datetime
|
||||
import hashlib
|
||||
|
||||
import ddt
|
||||
import factory
|
||||
import pytz
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.cache import cache
|
||||
from django.db.models import signals
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
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.djangolib.testing.utils import skip_unless_lms
|
||||
from student.models import CourseEnrollment
|
||||
@@ -112,14 +115,18 @@ class CourseEnrollmentTests(SharedModuleStoreTestCase):
|
||||
self.assertListEqual([self.user, self.user_2], all_enrolled_users)
|
||||
|
||||
@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):
|
||||
""" The property should use either the CourseMode or related Schedule to determine the deadline. """
|
||||
course_mode = CourseModeFactory(
|
||||
course_id=self.course.id,
|
||||
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)
|
||||
self.assertEqual(Schedule.objects.all().count(), 0)
|
||||
self.assertEqual(enrollment.upgrade_deadline, course_mode.expiration_datetime)
|
||||
|
||||
# The schedule's upgrade deadline should be used if a schedule exists
|
||||
|
||||
@@ -237,18 +237,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase):
|
||||
# # of sql queries to default,
|
||||
# # of mongo queries,
|
||||
# )
|
||||
('no_overrides', 1, True, False): (24, 1),
|
||||
('no_overrides', 2, True, False): (24, 1),
|
||||
('no_overrides', 3, True, False): (24, 1),
|
||||
('ccx', 1, True, False): (24, 1),
|
||||
('ccx', 2, True, False): (24, 1),
|
||||
('ccx', 3, True, False): (24, 1),
|
||||
('no_overrides', 1, False, False): (24, 1),
|
||||
('no_overrides', 2, False, False): (24, 1),
|
||||
('no_overrides', 3, False, False): (24, 1),
|
||||
('ccx', 1, False, False): (24, 1),
|
||||
('ccx', 2, False, False): (24, 1),
|
||||
('ccx', 3, False, False): (24, 1),
|
||||
('no_overrides', 1, True, False): (25, 1),
|
||||
('no_overrides', 2, True, False): (25, 1),
|
||||
('no_overrides', 3, True, False): (25, 1),
|
||||
('ccx', 1, True, False): (25, 1),
|
||||
('ccx', 2, True, False): (25, 1),
|
||||
('ccx', 3, True, False): (25, 1),
|
||||
('no_overrides', 1, False, False): (25, 1),
|
||||
('no_overrides', 2, False, False): (25, 1),
|
||||
('no_overrides', 3, False, False): (25, 1),
|
||||
('ccx', 1, False, False): (25, 1),
|
||||
('ccx', 2, False, False): (25, 1),
|
||||
('ccx', 3, False, False): (25, 1),
|
||||
}
|
||||
|
||||
|
||||
@@ -260,19 +260,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase):
|
||||
__test__ = True
|
||||
|
||||
TEST_DATA = {
|
||||
('no_overrides', 1, True, False): (24, 3),
|
||||
('no_overrides', 2, True, False): (24, 3),
|
||||
('no_overrides', 3, True, False): (24, 3),
|
||||
('ccx', 1, True, False): (24, 3),
|
||||
('ccx', 2, True, False): (24, 3),
|
||||
('ccx', 3, True, False): (24, 3),
|
||||
('ccx', 1, True, True): (25, 3),
|
||||
('ccx', 2, True, True): (25, 3),
|
||||
('ccx', 3, True, True): (25, 3),
|
||||
('no_overrides', 1, False, False): (24, 3),
|
||||
('no_overrides', 2, False, False): (24, 3),
|
||||
('no_overrides', 3, False, False): (24, 3),
|
||||
('ccx', 1, False, False): (24, 3),
|
||||
('ccx', 2, False, False): (24, 3),
|
||||
('ccx', 3, False, False): (24, 3),
|
||||
('no_overrides', 1, True, False): (25, 3),
|
||||
('no_overrides', 2, True, False): (25, 3),
|
||||
('no_overrides', 3, True, False): (25, 3),
|
||||
('ccx', 1, True, False): (25, 3),
|
||||
('ccx', 2, True, False): (25, 3),
|
||||
('ccx', 3, True, False): (25, 3),
|
||||
('ccx', 1, True, True): (26, 3),
|
||||
('ccx', 2, True, True): (26, 3),
|
||||
('ccx', 3, True, True): (26, 3),
|
||||
('no_overrides', 1, False, False): (25, 3),
|
||||
('no_overrides', 2, False, False): (25, 3),
|
||||
('no_overrides', 3, False, False): (25, 3),
|
||||
('ccx', 1, False, False): (25, 3),
|
||||
('ccx', 2, False, False): (25, 3),
|
||||
('ccx', 3, False, False): (25, 3),
|
||||
}
|
||||
|
||||
@@ -14,10 +14,8 @@ from lazy import lazy
|
||||
from pytz import timezone, utc
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from courseware.models import CourseDynamicUpgradeDeadlineConfiguration, DynamicUpgradeDeadlineConfiguration
|
||||
from lms.djangoapps.commerce.utils import EcommerceService
|
||||
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
|
||||
@@ -224,10 +222,6 @@ class VerifiedUpgradeDeadlineDate(DateSummary):
|
||||
def enrollment(self):
|
||||
return CourseEnrollment.get_enrollment(self.user, self.course_id)
|
||||
|
||||
@cached_property
|
||||
def course_overview(self):
|
||||
return CourseOverview.get_from_id(self.course_id)
|
||||
|
||||
@property
|
||||
def is_enabled(self):
|
||||
"""
|
||||
@@ -258,36 +252,8 @@ class VerifiedUpgradeDeadlineDate(DateSummary):
|
||||
def date(self):
|
||||
deadline = None
|
||||
|
||||
try:
|
||||
verified_mode = CourseMode.objects.get(course_id=self.course_id, mode_slug=CourseMode.VERIFIED)
|
||||
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)
|
||||
if self.enrollment:
|
||||
deadline = self.enrollment.upgrade_deadline
|
||||
|
||||
return deadline
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import ddt
|
||||
import waffle
|
||||
from django.core.urlresolvers import reverse
|
||||
from freezegun import freeze_time
|
||||
from nose.plugins.attrib import attr
|
||||
@@ -34,6 +35,7 @@ from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
@attr(shard=1)
|
||||
@ddt.ddt
|
||||
@waffle.testutils.override_switch('schedules.enable-create-schedule-receiver', True)
|
||||
class CourseDateSummaryTest(SharedModuleStoreTestCase):
|
||||
"""Tests for course date summary blocks."""
|
||||
|
||||
@@ -171,12 +173,12 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase):
|
||||
|
||||
@ddt.data(
|
||||
# Course not started
|
||||
({}, (CourseStartDate, TodaysDate, CourseEndDate, VerifiedUpgradeDeadlineDate)),
|
||||
({}, (CourseStartDate, TodaysDate, CourseEndDate)),
|
||||
# Course active
|
||||
({'days_till_start': -1}, (TodaysDate, CourseEndDate, VerifiedUpgradeDeadlineDate)),
|
||||
({'days_till_start': -1}, (TodaysDate, CourseEndDate)),
|
||||
# Course ended
|
||||
({'days_till_start': -10, 'days_till_end': -5},
|
||||
(TodaysDate, CourseEndDate, VerifiedUpgradeDeadlineDate)),
|
||||
(TodaysDate, CourseEndDate)),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_enabled_block_types_without_enrollment(self, course_kwargs, expected_blocks):
|
||||
|
||||
@@ -211,8 +211,8 @@ class IndexQueryTestCase(ModuleStoreTestCase):
|
||||
NUM_PROBLEMS = 20
|
||||
|
||||
@ddt.data(
|
||||
(ModuleStoreEnum.Type.mongo, 10, 145),
|
||||
(ModuleStoreEnum.Type.split, 4, 145),
|
||||
(ModuleStoreEnum.Type.mongo, 10, 146),
|
||||
(ModuleStoreEnum.Type.split, 4, 146),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count):
|
||||
@@ -1444,12 +1444,12 @@ class ProgressPageTests(ProgressPageBaseTests):
|
||||
"""Test that query counts remain the same for self-paced and instructor-paced courses."""
|
||||
SelfPacedConfiguration(enabled=self_paced_enabled).save()
|
||||
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()
|
||||
|
||||
@ddt.data(
|
||||
(False, 41, 27),
|
||||
(True, 34, 23)
|
||||
(False, 42, 28),
|
||||
(True, 35, 24)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_progress_queries(self, enable_waffle, initial, subsequent):
|
||||
|
||||
@@ -148,9 +148,9 @@ class RenderXBlockTestMixin(object):
|
||||
return response
|
||||
|
||||
@ddt.data(
|
||||
('vertical_block', ModuleStoreEnum.Type.mongo, 14),
|
||||
('vertical_block', ModuleStoreEnum.Type.mongo, 10),
|
||||
('vertical_block', ModuleStoreEnum.Type.split, 6),
|
||||
('html_block', ModuleStoreEnum.Type.mongo, 15),
|
||||
('html_block', ModuleStoreEnum.Type.mongo, 11),
|
||||
('html_block', ModuleStoreEnum.Type.split, 6),
|
||||
)
|
||||
@ddt.unpack
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
default_app_config = 'openedx.core.djangoapps.schedules.apps.SchedulesConfig'
|
||||
|
||||
11
openedx/core/djangoapps/schedules/apps.py
Normal file
11
openedx/core/djangoapps/schedules/apps.py
Normal file
@@ -0,0 +1,11 @@
|
||||
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
|
||||
70
openedx/core/djangoapps/schedules/signals.py
Normal file
70
openedx/core/djangoapps/schedules/signals.py
Normal file
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
24
openedx/core/djangoapps/schedules/tests/test_signals.py
Normal file
24
openedx/core/djangoapps/schedules/tests/test_signals.py
Normal file
@@ -0,0 +1,24 @@
|
||||
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):
|
||||
course_home_url(self.course)
|
||||
|
||||
# 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):
|
||||
url = course_home_url(self.course)
|
||||
self.client.get(url)
|
||||
|
||||
@@ -127,7 +127,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase):
|
||||
course_updates_url(self.course)
|
||||
|
||||
# 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):
|
||||
url = course_updates_url(self.course)
|
||||
self.client.get(url)
|
||||
|
||||
Reference in New Issue
Block a user