Merge pull request #23357 from edx/mikix/reset-on-mode
Reset schedule when mode changes
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
80
openedx/core/djangoapps/schedules/tests/test_utils.py
Normal file
80
openedx/core/djangoapps/schedules/tests/test_utils.py
Normal file
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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)]))
|
||||
|
||||
Reference in New Issue
Block a user