diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 69f59f480a..48b6c80c42 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -1303,7 +1303,8 @@ class CourseEnrollment(models.Model): sender=None, user=self.user, course_key=self.course_id, - countdown=SCORE_RECALCULATION_DELAY_ON_ENROLLMENT_UPDATE + mode=self.mode, + countdown=SCORE_RECALCULATION_DELAY_ON_ENROLLMENT_UPDATE, ) def send_signal(self, event, cost=None, currency=None): diff --git a/common/djangoapps/student/signals/signals.py b/common/djangoapps/student/signals/signals.py index bd40a31244..41a10d0dad 100644 --- a/common/djangoapps/student/signals/signals.py +++ b/common/djangoapps/student/signals/signals.py @@ -5,7 +5,7 @@ Enrollment track related signals. from django.dispatch import Signal -ENROLLMENT_TRACK_UPDATED = Signal(providing_args=['user', 'course_key']) +ENROLLMENT_TRACK_UPDATED = Signal(providing_args=['user', 'course_key', 'mode', 'countdown']) UNENROLL_DONE = Signal(providing_args=["course_enrollment", "skip_refund"]) ENROLL_STATUS_CHANGE = Signal(providing_args=["event", "user", "course_id", "mode", "cost", "currency"]) REFUND_ORDER = Signal(providing_args=["course_enrollment"]) diff --git a/openedx/core/djangoapps/schedules/signals.py b/openedx/core/djangoapps/schedules/signals.py index 3bfbed629d..0dbc6a7ee8 100644 --- a/openedx/core/djangoapps/schedules/signals.py +++ b/openedx/core/djangoapps/schedules/signals.py @@ -20,8 +20,10 @@ from lms.djangoapps.courseware.models import ( from openedx.core.djangoapps.content.course_overviews.signals import COURSE_START_DATE_CHANGED from openedx.core.djangoapps.schedules.content_highlights import course_has_highlights from openedx.core.djangoapps.schedules.models import ScheduleExperience +from openedx.core.djangoapps.schedules.utils import reset_self_paced_schedule from openedx.core.djangoapps.theming.helpers import get_current_site from student.models import CourseEnrollment +from student.signals import ENROLLMENT_TRACK_UPDATED from track import segment from .config import CREATE_SCHEDULE_WAFFLE_FLAG @@ -78,6 +80,18 @@ def update_schedules_on_course_start_changed(sender, updated_course_overview, pr ) +@receiver(ENROLLMENT_TRACK_UPDATED) +def reset_schedule_on_mode_change(sender, user, course_key, mode, **kwargs): # pylint: disable=unused-argument + """ + When a CourseEnrollment's mode is changed, reset the user's schedule if self-paced. + """ + # If switching to audit, reset to when the user got access to course. This is for the case where a user + # upgrades to verified (resetting their date), then later refunds the order and goes back to audit. We want + # to make sure that audit users go back to their normal audit schedule access. + use_availability_date = mode in CourseMode.AUDIT_MODES + reset_self_paced_schedule(user, course_key, use_availability_date=use_availability_date) + + def _calculate_upgrade_deadline(course_id, content_availability_date): upgrade_deadline = None diff --git a/openedx/core/djangoapps/schedules/tests/test_signals.py b/openedx/core/djangoapps/schedules/tests/test_signals.py index 0588cbf3a4..d02bea94f0 100644 --- a/openedx/core/djangoapps/schedules/tests/test_signals.py +++ b/openedx/core/djangoapps/schedules/tests/test_signals.py @@ -226,6 +226,49 @@ class UpdateScheduleTests(SharedModuleStoreTestCase): self.assert_schedule_dates(enrollment.schedule, course.start) # start set to new course start +@skip_unless_lms +@override_waffle_flag(CREATE_SCHEDULE_WAFFLE_FLAG, True) +class ResetScheduleTests(SharedModuleStoreTestCase): + def setUp(self): + super().setUp() + + self.config = ScheduleConfigFactory(create_schedules=True) + + site_patch = patch('openedx.core.djangoapps.schedules.signals.get_current_site', return_value=self.config.site) + self.addCleanup(site_patch.stop) + site_patch.start() + + self.course = _create_course_run(self_paced=True) + self.enrollment = CourseEnrollmentFactory( + course_id=self.course.id, + mode=CourseMode.AUDIT, + ) + self.schedule = self.enrollment.schedule + + def test_schedule_is_reset_after_enrollment_change(self): + """ Test that an update in enrollment causes a schedule reset. """ + original_start = self.schedule.start_date + + CourseEnrollment.enroll(self.enrollment.user, self.course.id, mode=CourseMode.VERIFIED) + + self.schedule.refresh_from_db() + self.assertGreater(self.schedule.start_date, original_start) # should have been reset to current time + + def test_schedule_is_reset_to_availabilty_date(self): + """ Test that a switch to audit enrollment resets to the availabilty date, not current time. """ + original_start = self.schedule.start_date + + # Switch to verified, confirm we change start date + CourseEnrollment.enroll(self.enrollment.user, self.course.id, mode=CourseMode.VERIFIED) + self.schedule.refresh_from_db() + self.assertNotEqual(self.schedule.start_date, original_start) + + # Switch back to audit, confirm we change back to original availability date + CourseEnrollment.enroll(self.enrollment.user, self.course.id, mode=CourseMode.AUDIT) + self.schedule.refresh_from_db() + self.assertEqual(self.schedule.start_date, original_start) + + def _create_course_run(self_paced=True, start_day_offset=-1): """ Create a new course run and course modes. diff --git a/openedx/core/djangoapps/schedules/tests/test_utils.py b/openedx/core/djangoapps/schedules/tests/test_utils.py new file mode 100644 index 0000000000..70704948be --- /dev/null +++ b/openedx/core/djangoapps/schedules/tests/test_utils.py @@ -0,0 +1,80 @@ +""" +Tests for schedules utils +""" + +import datetime + +import ddt +from course_modes.models import CourseMode +from mock import patch +from pytz import utc + +from openedx.core.djangoapps.schedules.models import Schedule +from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory +from openedx.core.djangoapps.schedules.utils import reset_self_paced_schedule +from openedx.core.djangolib.testing.utils import skip_unless_lms +from student.tests.factories import CourseEnrollmentFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +@ddt.ddt +@skip_unless_lms +class ResetSelfPacedScheduleTests(SharedModuleStoreTestCase): + def create_schedule(self, offset=0): + self.config = ScheduleConfigFactory(create_schedules=True) + + site_patch = patch('openedx.core.djangoapps.schedules.signals.get_current_site', return_value=self.config.site) + self.addCleanup(site_patch.stop) + site_patch.start() + + start = datetime.datetime.now(utc) - datetime.timedelta(days=100) + self.course = CourseFactory.create(start=start, self_paced=True) + + self.enrollment = CourseEnrollmentFactory( + course_id=self.course.id, + mode=CourseMode.AUDIT, + ) + self.enrollment.created = start + datetime.timedelta(days=offset) + self.enrollment.save() + + self.schedule = self.enrollment.schedule + self.schedule.start_date = self.enrollment.created + self.schedule.save() + + self.user = self.enrollment.user + + def test_reset_to_now(self): + self.create_schedule() + original_start = self.schedule.start_date + + with self.assertNumQueries(1): + reset_self_paced_schedule(self.user, self.course.id, use_availability_date=False) + + self.schedule.refresh_from_db() + self.assertGreater(self.schedule.start_date, original_start) + + @ddt.data( + (-1, 0), # enrolled before course started (will reset to start date) + (1, 1), # enrolled after course started (will reset to enroll date) + ) + @ddt.unpack + def test_reset_to_start_date(self, offset, expected_offset): + self.create_schedule(offset=offset) + expected_start = self.course.start + datetime.timedelta(days=expected_offset) + + with self.assertNumQueries(1): + reset_self_paced_schedule(self.user, self.course.id, use_availability_date=True) + + self.schedule.refresh_from_db() + self.assertEqual(self.schedule.start_date.replace(microsecond=0), expected_start.replace(microsecond=0)) + + def test_safe_without_schedule(self): + """ Just ensure that we don't raise exceptions or create any schedules """ + self.create_schedule() + self.schedule.delete() + + reset_self_paced_schedule(self.user, self.course.id, use_availability_date=False) + reset_self_paced_schedule(self.user, self.course.id, use_availability_date=True) + + self.assertEqual(Schedule.objects.count(), 0) diff --git a/openedx/core/djangoapps/schedules/utils.py b/openedx/core/djangoapps/schedules/utils.py index 6729b905bf..760448a4fa 100644 --- a/openedx/core/djangoapps/schedules/utils.py +++ b/openedx/core/djangoapps/schedules/utils.py @@ -1,7 +1,14 @@ +import datetime import logging +import pytz +from django.db.models import F, Subquery +from django.db.models.functions import Greatest + +from openedx.core.djangoapps.schedules.models import Schedule + LOG = logging.getLogger(__name__) @@ -26,3 +33,28 @@ class PrefixedDebugLoggerMixin(object): Wrapper around LOG.info that prefixes the message. """ LOG.info(self.log_prefix + ': ' + message, *args, **kwargs) + + +def reset_self_paced_schedule(user, course_key, use_availability_date=False): + """ + Reset the user's schedule if self-paced. + + It does not create a new schedule, just resets an existing one. + This is used, for example, when a user requests it or when an enrollment mode changes. + + Arguments: + user (User) + course_key (CourseKey or str) + use_availability_date (bool): if False, reset to now, else reset to when user got access to course material + """ + schedule = Schedule.objects.filter( + enrollment__user=user, + enrollment__course__id=course_key, + enrollment__course__self_paced=True, + ) + + if use_availability_date: + schedule = schedule.annotate(start_of_access=Greatest(F('enrollment__created'), F('enrollment__course__start'))) + schedule.update(start_date=Subquery(schedule.values('start_of_access')[:1])) + else: + schedule.update(start_date=datetime.datetime.now(pytz.utc)) diff --git a/openedx/features/course_experience/views/course_outline.py b/openedx/features/course_experience/views/course_outline.py index a4a808d4c0..deac9b5cea 100644 --- a/openedx/features/course_experience/views/course_outline.py +++ b/openedx/features/course_experience/views/course_outline.py @@ -5,7 +5,6 @@ Views to show a course outline. import datetime import re -import pytz import six from completion import waffle as completion_waffle @@ -24,9 +23,8 @@ from web_fragments.fragment import Fragment from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.courses import get_course_overview_with_access from lms.djangoapps.courseware.masquerade import setup_masquerade -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.plugin_api.views import EdxFragmentView -from student.models import CourseEnrollment +from openedx.core.djangoapps.schedules.utils import reset_self_paced_schedule from util.milestones_helpers import get_course_content_milestones from xmodule.course_module import COURSE_VISIBILITY_PUBLIC from xmodule.modulestore.django import modulestore @@ -171,21 +169,15 @@ def reset_course_deadlines(request, course_id): sequentials belonging to a self paced course """ course_key = CourseKey.from_string(course_id) - course = CourseOverview.objects.get(id=course_key) - if course.self_paced: - masquerade_details, masquerade_user = setup_masquerade( - request, - course_key, - has_access(request.user, 'staff', course, course_key) - ) - if masquerade_details and masquerade_details.role == 'student' and masquerade_details.user_name: - # Masquerading as a specific student, so reset that student's schedule - user = masquerade_user - else: - user = request.user - enrollment = CourseEnrollment.objects.get(user=user, course=course_key) - schedule = enrollment.schedule - if schedule: - schedule.start_date = datetime.datetime.now(pytz.utc) - schedule.save() + masquerade_details, masquerade_user = setup_masquerade( + request, + course_key, + has_access(request.user, 'staff', course_key) + ) + if masquerade_details and masquerade_details.role == 'student' and masquerade_details.user_name: + # Masquerading as a specific student, so reset that student's schedule + user = masquerade_user + else: + user = request.user + reset_self_paced_schedule(user, course_key) return redirect(reverse('openedx.course_experience.course_home', args=[six.text_type(course_key)]))