diff --git a/openedx/features/course_experience/api/v1/tests/test_utils.py b/openedx/features/course_experience/api/v1/tests/test_utils.py new file mode 100644 index 000000000000..741a2d7658f1 --- /dev/null +++ b/openedx/features/course_experience/api/v1/tests/test_utils.py @@ -0,0 +1,117 @@ +""" +Tests utils of course expirience feature. +""" +import datetime + +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIRequestFactory + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.util.testing import EventTestMixin +from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests +from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin +from openedx.core.djangoapps.schedules.models import Schedule +from openedx.features.course_experience.api.v1.utils import ( + reset_deadlines_for_course, + reset_course_deadlines_for_user, + reset_bulk_course_deadlines +) +from xmodule.modulestore.tests.factories import CourseFactory + + +class TestResetDeadlinesForCourse(EventTestMixin, BaseCourseHomeTests, MasqueradeMixin): + """ + Tests for reset deadlines endpoint. + """ + def setUp(self): # pylint: disable=arguments-differ + super().setUp("openedx.features.course_experience.api.v1.utils.tracker") + self.course = CourseFactory.create(self_paced=True, start=timezone.now() - datetime.timedelta(days=1000)) + + def test_reset_deadlines_for_course(self): + enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) + enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=100) + enrollment.schedule.save() + + request = APIRequestFactory().post( + reverse("course-experience-reset-course-deadlines"), {"course_key": self.course.id} + ) + request.user = self.user + + reset_deadlines_for_course(request, self.course.id, {}) + + assert enrollment.schedule.start_date < Schedule.objects.get(id=enrollment.schedule.id).start_date + self.assert_event_emitted( + "edx.ui.lms.reset_deadlines.clicked", + courserun_key=str(self.course.id), + is_masquerading=False, + is_staff=False, + org_key=self.course.org, + user_id=self.user.id, + ) + + def test_reset_deadlines_with_masquerade(self): + """Staff users should be able to masquerade as a learner and reset the learner's schedule""" + student_username = self.user.username + student_user_id = self.user.id + student_enrollment = CourseEnrollment.enroll(self.user, self.course.id) + student_enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=100) + student_enrollment.schedule.save() + + staff_enrollment = CourseEnrollment.enroll(self.staff_user, self.course.id) + staff_enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=30) + staff_enrollment.schedule.save() + + self.switch_to_staff() + self.update_masquerade(course=self.course, username=student_username) + + request = APIRequestFactory().post( + reverse("course-experience-reset-course-deadlines"), {"course_key": self.course.id} + ) + request.user = self.staff_user + request.session = self.client.session + + reset_deadlines_for_course(request, self.course.id, {}) + + updated_schedule = Schedule.objects.get(id=student_enrollment.schedule.id) + assert updated_schedule.start_date.date() == datetime.datetime.today().date() + updated_staff_schedule = Schedule.objects.get(id=staff_enrollment.schedule.id) + assert updated_staff_schedule.start_date == staff_enrollment.schedule.start_date + self.assert_event_emitted( + "edx.ui.lms.reset_deadlines.clicked", + courserun_key=str(self.course.id), + is_masquerading=True, + is_staff=False, + org_key=self.course.org, + user_id=student_user_id, + ) + + def test_reset_course_deadlines_for_user(self): + """Test the reset_course_deadlines_for_user utility function directly""" + enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) + enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=100) + enrollment.schedule.save() + + result = reset_course_deadlines_for_user(self.user, self.course.id) + + assert result is True + assert enrollment.schedule.start_date < Schedule.objects.get(id=enrollment.schedule.id).start_date + + def test_reset_bulk_course_deadlines(self): + """Test the reset_bulk_course_deadlines utility function""" + enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) + enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=100) + enrollment.schedule.save() + + request = APIRequestFactory().post( + reverse("course-experience-reset-all-course-deadlines"), {} + ) + request.user = self.user + + success_keys, failed_keys = reset_bulk_course_deadlines(request, [self.course.id], {}) + + assert len(success_keys) == 1 + assert self.course.id in success_keys + assert len(failed_keys) == 0 + assert enrollment.schedule.start_date < Schedule.objects.get(id=enrollment.schedule.id).start_date diff --git a/openedx/features/course_experience/api/v1/tests/test_views.py b/openedx/features/course_experience/api/v1/tests/test_views.py index 8cef39053b3e..097eb2c18c3b 100644 --- a/openedx/features/course_experience/api/v1/tests/test_views.py +++ b/openedx/features/course_experience/api/v1/tests/test_views.py @@ -1,7 +1,9 @@ """ Tests for reset deadlines endpoint. """ + import datetime +from unittest import mock import ddt from django.urls import reverse @@ -10,7 +12,6 @@ from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.util.testing import EventTestMixin from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin from openedx.core.djangoapps.schedules.models import Schedule @@ -19,14 +20,12 @@ @ddt.ddt -class ResetCourseDeadlinesViewTests(EventTestMixin, BaseCourseHomeTests, MasqueradeMixin): +class ResetCourseDeadlinesViewTests(BaseCourseHomeTests, MasqueradeMixin): """ Tests for reset deadlines endpoint. """ def setUp(self): # pylint: disable=arguments-differ - # Need to supply tracker name for the EventTestMixin. Also, EventTestMixin needs to come - # first in class inheritance so the setUp call here appropriately works - super().setUp('openedx.features.course_experience.api.v1.views.tracker') + super().setUp() self.course = CourseFactory.create(self_paced=True, start=timezone.now() - datetime.timedelta(days=1000)) def test_reset_deadlines(self): @@ -37,20 +36,11 @@ def test_reset_deadlines(self): response = self.client.post(reverse('course-experience-reset-course-deadlines'), {'course': self.course.id}) assert response.status_code == 400 assert enrollment.schedule == Schedule.objects.get(id=enrollment.schedule.id) - self.assert_no_events_were_emitted() # Test correct post body response = self.client.post(reverse('course-experience-reset-course-deadlines'), {'course_key': self.course.id}) assert response.status_code == 200 assert enrollment.schedule.start_date < Schedule.objects.get(id=enrollment.schedule.id).start_date - self.assert_event_emitted( - 'edx.ui.lms.reset_deadlines.clicked', - courserun_key=str(self.course.id), - is_masquerading=False, - is_staff=False, - org_key=self.course.org, - user_id=self.user.id, - ) @override_waffle_flag(RELATIVE_DATES_FLAG, active=True) @override_waffle_flag(RELATIVE_DATES_DISABLE_RESET_FLAG, active=True) @@ -62,36 +52,6 @@ def test_reset_deadlines_disabled(self): response = self.client.post(reverse('course-experience-reset-course-deadlines'), {'course_key': self.course.id}) assert response.status_code == 200 assert enrollment.schedule == Schedule.objects.get(id=enrollment.schedule.id) - self.assert_no_events_were_emitted() - - def test_reset_deadlines_with_masquerade(self): - """ Staff users should be able to masquerade as a learner and reset the learner's schedule """ - student_username = self.user.username - student_user_id = self.user.id - student_enrollment = CourseEnrollment.enroll(self.user, self.course.id) - student_enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=100) - student_enrollment.schedule.save() - - staff_enrollment = CourseEnrollment.enroll(self.staff_user, self.course.id) - staff_enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=30) - staff_enrollment.schedule.save() - - self.switch_to_staff() - self.update_masquerade(course=self.course, username=student_username) - - self.client.post(reverse('course-experience-reset-course-deadlines'), {'course_key': self.course.id}) - updated_schedule = Schedule.objects.get(id=student_enrollment.schedule.id) - assert updated_schedule.start_date.date() == datetime.datetime.today().date() - updated_staff_schedule = Schedule.objects.get(id=staff_enrollment.schedule.id) - assert updated_staff_schedule.start_date == staff_enrollment.schedule.start_date - self.assert_event_emitted( - 'edx.ui.lms.reset_deadlines.clicked', - courserun_key=str(self.course.id), - is_masquerading=True, - is_staff=False, - org_key=self.course.org, - user_id=student_user_id, - ) def test_post_unauthenticated_user(self): self.client.logout() @@ -115,3 +75,52 @@ def test_mobile_get_unauthenticated_user(self): self.client.logout() response = self.client.get(reverse('course-experience-course-deadlines-mobile', args=[self.course.id])) assert response.status_code == 401 + + +class ResetAllRelativeCourseDeadlinesViewTests(BaseCourseHomeTests, MasqueradeMixin): + """ + Tests for reset all relative deadlines endpoint. + """ + + def setUp(self): # pylint: disable=arguments-differ + super().setUp() + self.course = CourseFactory.create(self_paced=True, start=timezone.now() - datetime.timedelta(days=1000)) + self.enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) + self.enrollment.schedule.start_date = timezone.now() - datetime.timedelta(days=100) + self.enrollment.schedule.save() + + def test_reset_all_course_deadlines(self): + """ + Test reset all course deadlines endpoint + """ + response = self.client.post( + reverse("course-experience-reset-all-course-deadlines"), + {}, + ) + assert response.status_code == 200 + assert self.enrollment.schedule.start_date < Schedule.objects.get(id=self.enrollment.schedule.id).start_date + assert str(self.course.id) in response.data.get("success_course_keys") + + def test_reset_all_course_deadlines_failure(self): + """ + Raise exception on reset_bulk_course_deadlines and assert if failure course id is returned + """ + with mock.patch( + "openedx.features.course_experience.api.v1.views.reset_bulk_course_deadlines", + return_value=([], [self.course.id]), + ): + response = self.client.post(reverse("course-experience-reset-all-course-deadlines"), {}) + + assert response.status_code == 200 + assert str(self.course.id) in response.data.get("failed_course_keys") + + def test_post_unauthenticated_user(self): + """ + Test reset all relative course deadlines endpoint for unauthenticated user + """ + self.client.logout() + response = self.client.post( + reverse("course-experience-reset-all-course-deadlines"), + {}, + ) + assert response.status_code == 401 diff --git a/openedx/features/course_experience/api/v1/urls.py b/openedx/features/course_experience/api/v1/urls.py index 9a2c7106cd1b..2c84af437f70 100644 --- a/openedx/features/course_experience/api/v1/urls.py +++ b/openedx/features/course_experience/api/v1/urls.py @@ -4,9 +4,13 @@ from django.conf import settings -from django.urls import re_path +from django.urls import re_path, path -from openedx.features.course_experience.api.v1.views import reset_course_deadlines, CourseDeadlinesMobileView +from openedx.features.course_experience.api.v1.views import ( + reset_course_deadlines, + reset_all_course_deadlines, + CourseDeadlinesMobileView, +) urlpatterns = [] @@ -17,6 +21,11 @@ reset_course_deadlines, name='course-experience-reset-course-deadlines' ), + path( + 'v1/reset_all_course_deadlines/', + reset_all_course_deadlines, + name='course-experience-reset-all-course-deadlines', + ) ] # URL for retrieving course deadlines info diff --git a/openedx/features/course_experience/api/v1/utils.py b/openedx/features/course_experience/api/v1/utils.py new file mode 100644 index 000000000000..8f9205b0f113 --- /dev/null +++ b/openedx/features/course_experience/api/v1/utils.py @@ -0,0 +1,115 @@ + +""" +Course Experience API utilities. +""" +import logging +from eventtracking import tracker + +from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.courseware.masquerade import is_masquerading, setup_masquerade +from lms.djangoapps.course_api.api import course_detail +from openedx.core.djangoapps.schedules.utils import reset_self_paced_schedule +from openedx.features.course_experience.utils import dates_banner_should_display + + +logger = logging.getLogger(__name__) + + +def reset_course_deadlines_for_user(user, course_key): + """ + Core function to reset deadlines for a single course and user. + + Args: + user: The user object + course_key: The course key + + Returns: + bool: True if deadlines were reset, False if gated content prevents reset + """ + # We ignore the missed_deadlines because this util is used in endpoint from the Learning MFE for + # learners who have remaining attempts on a problem and reset their due dates in order to + # submit additional attempts. This can apply for 'completed' (submitted) content that would + # not be marked as past_due + _missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, user) + if not missed_gated_content: + reset_self_paced_schedule(user, course_key) + return True + return False + + +def reset_bulk_course_deadlines(request, course_keys, research_event_data={}): # lint-amnesty, pylint: disable=dangerous-default-value + """ + Reset deadlines for multiple courses for the requesting user. + + Args: + request (Request): The request object + course_keys (list): List of course keys + research_event_data (dict): Any data that should be included in the research tracking event + + Returns: + tuple: (success_course_keys, failed_course_keys) + """ + success_course_keys = [] + failed_course_keys = [] + + for course_key in course_keys: + try: + course_masquerade, user = setup_masquerade( + request, + course_key, + has_access(request.user, 'staff', course_key) + ) + + if reset_course_deadlines_for_user(user, course_key): + success_course_keys.append(course_key) + + course_overview = course_detail(request, user.username, course_key) + + research_event_data.update({ + 'courserun_key': str(course_key), + 'is_masquerading': is_masquerading(user, course_key, course_masquerade), + 'is_staff': has_access(user, 'staff', course_key).has_access, + 'org_key': course_overview.display_org_with_default, + 'user_id': user.id, + }) + tracker.emit('edx.ui.lms.reset_deadlines.clicked', research_event_data) + else: + failed_course_keys.append(course_key) + except Exception: # pylint: disable=broad-exception-caught + logger.exception('Error occurred while trying to reset deadlines!') + failed_course_keys.append(course_key) + + return success_course_keys, failed_course_keys + + +def reset_deadlines_for_course(request, course_key, research_event_data={}): # lint-amnesty, pylint: disable=dangerous-default-value + """ + Set the start_date of a schedule to today, which in turn will adjust due dates for + sequentials belonging to a self paced course + + Args: + request (Request): The request object + course_key (str): The course key + research_event_data (dict): Any data that should be included in the research tracking event + Example: sending the location of where the reset deadlines banner (i.e. outline-tab) + """ + + course_masquerade, user = setup_masquerade( + request, + course_key, + has_access(request.user, 'staff', course_key) + ) + + if reset_course_deadlines_for_user(user, course_key): + course_overview = course_detail(request, user.username, course_key) + # For context here, research_event_data should already contain `location` indicating + # the page/location dates were reset from and could also contain `block_id` if reset + # within courseware. + research_event_data.update({ + 'courserun_key': str(course_key), + 'is_masquerading': is_masquerading(user, course_key, course_masquerade), + 'is_staff': has_access(user, 'staff', course_key).has_access, + 'org_key': course_overview.display_org_with_default, + 'user_id': user.id, + }) + tracker.emit('edx.ui.lms.reset_deadlines.clicked', research_event_data) diff --git a/openedx/features/course_experience/api/v1/views.py b/openedx/features/course_experience/api/v1/views.py index 16be4a4e0ed4..d822fdd65c67 100644 --- a/openedx/features/course_experience/api/v1/views.py +++ b/openedx/features/course_experience/api/v1/views.py @@ -5,7 +5,6 @@ from django.utils.html import format_html from django.utils.translation import gettext as _ -from eventtracking import tracker from rest_framework.decorators import api_view, authentication_classes, permission_classes from rest_framework.exceptions import APIException, ParseError @@ -17,17 +16,14 @@ from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from opaque_keys.edx.keys import CourseKey -from lms.djangoapps.course_api.api import course_detail +from common.djangoapps.student.models import CourseEnrollment from lms.djangoapps.course_goals.models import UserActivity -from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.courses import get_course_with_access -from lms.djangoapps.courseware.masquerade import is_masquerading, setup_masquerade -from openedx.core.djangoapps.schedules.utils import reset_self_paced_schedule from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.features.course_experience.api.v1.serializers import CourseDeadlinesMobileSerializer from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url -from openedx.features.course_experience.utils import dates_banner_should_display +from openedx.features.course_experience.api.v1.utils import reset_deadlines_for_course, reset_bulk_course_deadlines log = logging.getLogger(__name__) @@ -65,32 +61,7 @@ def reset_course_deadlines(request): try: course_key = CourseKey.from_string(course_key) - course_masquerade, user = setup_masquerade( - request, - course_key, - has_access(request.user, 'staff', course_key) - ) - - # We ignore the missed_deadlines because this endpoint is used in the Learning MFE for - # learners who have remaining attempts on a problem and reset their due dates in order to - # submit additional attempts. This can apply for 'completed' (submitted) content that would - # not be marked as past_due - _missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, user) - if not missed_gated_content: - reset_self_paced_schedule(user, course_key) - - course_overview = course_detail(request, user.username, course_key) - # For context here, research_event_data should already contain `location` indicating - # the page/location dates were reset from and could also contain `block_id` if reset - # within courseware. - research_event_data.update({ - 'courserun_key': str(course_key), - 'is_masquerading': is_masquerading(user, course_key, course_masquerade), - 'is_staff': has_access(user, 'staff', course_key).has_access, - 'org_key': course_overview.display_org_with_default, - 'user_id': user.id, - }) - tracker.emit('edx.ui.lms.reset_deadlines.clicked', research_event_data) + reset_deadlines_for_course(request, course_key, research_event_data) body_link = get_learning_mfe_home_url(course_key=course_key, url_fragment='dates') @@ -106,6 +77,44 @@ def reset_course_deadlines(request): raise UnableToResetDeadlines from reset_deadlines_exception +@api_view(["POST"]) +@authentication_classes( + ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) +) +@permission_classes((IsAuthenticated,)) +def reset_all_course_deadlines(request): + """ + Set the start_date of a schedule to today for all enrolled courses + + Request Parameters: + research_event_data: any data that should be included in the research tracking event + Example: sending the location of where the reset deadlines banner (i.e. outline-tab) + + Returns: + success_course_keys: list of course keys for which deadlines were successfully reset + failed_course_keys: list of course keys for which deadlines could not be reset + """ + research_event_data = request.data.get("research_event_data", {}) + course_keys = list( + CourseEnrollment.enrollments_for_user(request.user).select_related("course").values_list("course_id", flat=True) + ) + + success_course_keys, failed_course_keys = reset_bulk_course_deadlines( + request, course_keys, research_event_data + ) + + return Response( + { + "success_course_keys": [str(key) for key in success_course_keys], + "failed_course_keys": [str(key) for key in failed_course_keys], + } + ) + + class CourseDeadlinesMobileView(RetrieveAPIView): """ **Use Cases**