Merge pull request #34303 from openedx/jkantor/list-available
feat: add list endpoint for course reset enabled enrollments
This commit is contained in:
@@ -28,6 +28,14 @@ class CourseResetCourseOptIn(TimeStampedModel):
|
||||
def __str__(self):
|
||||
return f'{self.course_id} - {"ACTIVE" if self.active else "INACTIVE"}'
|
||||
|
||||
@staticmethod
|
||||
def all_active():
|
||||
return CourseResetCourseOptIn.objects.filter(active=True)
|
||||
|
||||
@staticmethod
|
||||
def all_active_course_ids():
|
||||
return [course.course_id for course in CourseResetCourseOptIn.all_active()]
|
||||
|
||||
|
||||
class CourseResetAudit(TimeStampedModel):
|
||||
"""
|
||||
@@ -57,3 +65,15 @@ class CourseResetAudit(TimeStampedModel):
|
||||
default=CourseResetStatus.ENQUEUED,
|
||||
)
|
||||
completed_at = DateTimeField(default=None, null=True, blank=True)
|
||||
|
||||
def status_message(self):
|
||||
""" Return a string message about the status of this audit """
|
||||
if self.status == self.CourseResetStatus.FAILED:
|
||||
return f"Failed on {self.modified}"
|
||||
if self.status == self.CourseResetStatus.ENQUEUED:
|
||||
return f"Enqueued - Created {self.created} by {self.reset_by.username}"
|
||||
if self.status == self.CourseResetStatus.COMPLETE:
|
||||
return f"Completed on {self.completed_at} by {self.reset_by.username}"
|
||||
if self.status == self.CourseResetStatus.IN_PROGRESS:
|
||||
return f"In progress - Started on {self.modified} by {self.reset_by.username}"
|
||||
return self.status
|
||||
|
||||
29
lms/djangoapps/support/tests/factories.py
Normal file
29
lms/djangoapps/support/tests/factories.py
Normal file
@@ -0,0 +1,29 @@
|
||||
""" Factories for course reset models """
|
||||
import factory
|
||||
from factory.django import DjangoModelFactory
|
||||
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
|
||||
|
||||
from lms.djangoapps.support.models import (
|
||||
CourseResetCourseOptIn,
|
||||
CourseResetAudit
|
||||
)
|
||||
|
||||
|
||||
class CourseResetCourseOptInFactory(DjangoModelFactory): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
class Meta:
|
||||
model = CourseResetCourseOptIn
|
||||
|
||||
course_id = None
|
||||
active = True
|
||||
|
||||
|
||||
class CourseResetAuditFactory(DjangoModelFactory): # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
class Meta:
|
||||
model = CourseResetAudit
|
||||
|
||||
course = factory.SubFactory(CourseResetCourseOptInFactory)
|
||||
course_enrollment = factory.SubFactory(CourseEnrollmentFactory)
|
||||
reset_by = factory.SubFactory(UserFactory)
|
||||
status = CourseResetAudit.CourseResetStatus.ENQUEUED
|
||||
completed_at = None
|
||||
@@ -54,7 +54,9 @@ from common.djangoapps.student.tests.factories import (
|
||||
from common.djangoapps.third_party_auth.tests.factories import SAMLProviderConfigFactory
|
||||
from common.test.utils import disable_signal
|
||||
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
|
||||
from lms.djangoapps.support.models import CourseResetAudit
|
||||
from lms.djangoapps.support.serializers import ProgramEnrollmentSerializer
|
||||
from lms.djangoapps.support.tests.factories import CourseResetCourseOptInFactory, CourseResetAuditFactory
|
||||
from lms.djangoapps.verify_student.models import VerificationDeadline
|
||||
from lms.djangoapps.verify_student.services import IDVerificationService
|
||||
from lms.djangoapps.verify_student.tests.factories import SSOVerificationFactory
|
||||
@@ -2067,3 +2069,265 @@ class TestOnboardingView(SupportViewTestCase, ProctoredExamTestCase):
|
||||
# assert that most recent enrollment (current status) has other_course_approved status
|
||||
self.assertEqual(response_data['current_status']['onboarding_status'], 'other_course_approved')
|
||||
self.assertEqual(response_data['current_status']['course_id'], self.course_id)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestResetCourseViewGET(SupportViewTestCase):
|
||||
""" Tests for the list endpoint for course reset """
|
||||
|
||||
def _url(self, username):
|
||||
""" Helper to generate URL """
|
||||
return reverse("support:course_reset", kwargs={'username_or_email': username})
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Set permissions, create an open course and learner, enroll learner and opt into course reset
|
||||
"""
|
||||
super().setUp()
|
||||
SupportStaffRole().add_users(self.user)
|
||||
self.now = datetime.now().replace(tzinfo=UTC)
|
||||
|
||||
self.course = CourseFactory.create(
|
||||
start=self.now - timedelta(days=90),
|
||||
end=self.now + timedelta(days=90),
|
||||
)
|
||||
self.course_id = str(self.course.id)
|
||||
self.course_overview = CourseOverview.get_from_id(self.course.id)
|
||||
self.learner = UserFactory.create()
|
||||
self.enrollment = CourseEnrollmentFactory.create(user=self.learner, course_id=self.course.id)
|
||||
self.opt_in = CourseResetCourseOptInFactory.create(course_id=self.course.id)
|
||||
|
||||
def assert_course_ids(self, expected_course_ids, learner=None):
|
||||
""" Helper that asserts the course ids that will be returned from the listing endpoint """
|
||||
learner = learner or self.learner
|
||||
response = self.client.get(self._url(learner))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
actual_course_ids = [course['course_id'] for course in response.json()]
|
||||
self.assertEqual(expected_course_ids, actual_course_ids)
|
||||
|
||||
def test_no_enrollments(self):
|
||||
""" When a learner has no enrollments, the endpoint should return an empty list """
|
||||
no_enrollment_learner = UserFactory.create()
|
||||
self.assert_course_ids([], learner=no_enrollment_learner)
|
||||
|
||||
def test_not_opted_in(self):
|
||||
"""
|
||||
If a learner is enrolled in a course that is not opted into the course reset feature,
|
||||
it will not be returned by the endpoint
|
||||
"""
|
||||
non_opted_in_course = CourseFactory.create()
|
||||
enrollment = CourseEnrollmentFactory.create(user=self.learner, course_id=non_opted_in_course.id)
|
||||
self.assert_course_ids([self.course_id])
|
||||
|
||||
def test_deactivated_opt_in(self):
|
||||
"""
|
||||
If a learner is enrolled in a course that has opted in, but that opt-in is
|
||||
deactivated, it will not be returned from the endpoint
|
||||
"""
|
||||
response = self.client.get(self._url(self.learner))
|
||||
self.assert_course_ids([self.course_id])
|
||||
|
||||
self.opt_in.active = False
|
||||
self.opt_in.save()
|
||||
|
||||
self.assert_course_ids([])
|
||||
|
||||
def test_deactivated_enrollment(self):
|
||||
"""
|
||||
If a learner's enrollment in an opted in course is deactivated,
|
||||
the course will not be returned by the endpoint
|
||||
"""
|
||||
response = self.client.get(self._url(self.learner))
|
||||
self.assert_course_ids([self.course_id])
|
||||
|
||||
self.enrollment.is_active = False
|
||||
self.enrollment.save()
|
||||
|
||||
self.assert_course_ids([])
|
||||
|
||||
def assertResponse(self, expected_response, learner=None):
|
||||
""" Helper to assert the contents of the response from the listing endpoint """
|
||||
learner = learner or self.learner
|
||||
response = self.client.get(self._url(learner))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
actual_response = response.json()
|
||||
self.assertEqual(expected_response, actual_response)
|
||||
return actual_response
|
||||
|
||||
def test_course_not_started(self):
|
||||
""" If a course is opted in but has not started, it should not be resettable """
|
||||
self.course_overview.start = self.now + timedelta(days=10)
|
||||
self.course_overview.end = self.now + timedelta(days=11)
|
||||
self.course_overview.save()
|
||||
self.assertResponse([{
|
||||
'course_id': self.course_id,
|
||||
'display_name': self.course_overview.display_name,
|
||||
'can_reset': False,
|
||||
'status': 'Course Not Started'
|
||||
}])
|
||||
|
||||
def test_course_ended(self):
|
||||
""" If a course is opted in but has ended, it should not be resettable """
|
||||
self.course_overview.start = self.now - timedelta(days=11)
|
||||
self.course_overview.end = self.now - timedelta(days=10)
|
||||
self.course_overview.save()
|
||||
self.assertResponse([
|
||||
{
|
||||
'course_id': self.course_id,
|
||||
'display_name': self.course_overview.display_name,
|
||||
'can_reset': False,
|
||||
'status': 'Course Ended'
|
||||
}
|
||||
])
|
||||
|
||||
@patch('lms.djangoapps.support.views.course_reset.user_has_passing_grade_in_course', return_value=True)
|
||||
def test_user_has_passing_grade(self, _):
|
||||
""" If a course is opted in but the learner has a passing grade, it should not be resettable """
|
||||
self.assertResponse([{
|
||||
'course_id': self.course_id,
|
||||
'display_name': self.course_overview.display_name,
|
||||
'can_reset': False,
|
||||
'status': 'Learner Has Passing Grade'
|
||||
}])
|
||||
|
||||
@patch('lms.djangoapps.support.views.course_reset.user_has_passing_grade_in_course', return_value=True)
|
||||
def test_ended_with_passing_grade(self, _):
|
||||
"""
|
||||
If a course has ended and the learner has a passing grade,
|
||||
the passing grade message should override the ended message
|
||||
"""
|
||||
self.course_overview.start = self.now - timedelta(days=11)
|
||||
self.course_overview.end = self.now - timedelta(days=10)
|
||||
self.assertResponse([{
|
||||
'course_id': self.course_id,
|
||||
'display_name': self.course_overview.display_name,
|
||||
'can_reset': False,
|
||||
'status': 'Learner Has Passing Grade'
|
||||
}])
|
||||
|
||||
def test_available_course(self):
|
||||
""" If a course is opted in and had nothing stopping it from being reset, it should be resettable """
|
||||
self.assertResponse([{
|
||||
'course_id': self.course_id,
|
||||
'display_name': self.course.display_name,
|
||||
'can_reset': True,
|
||||
'status': 'Available'
|
||||
}])
|
||||
|
||||
@ddt.unpack
|
||||
@ddt.data(
|
||||
(CourseResetAudit.CourseResetStatus.ENQUEUED, False),
|
||||
(CourseResetAudit.CourseResetStatus.IN_PROGRESS, False),
|
||||
(CourseResetAudit.CourseResetStatus.FAILED, True),
|
||||
(CourseResetAudit.CourseResetStatus.COMPLETE, False),
|
||||
)
|
||||
def test_audit(self, audit_status, expected_can_reset):
|
||||
"""
|
||||
If a course enrollment has a CourseResetAudit associated with it,
|
||||
it should not be resettable unless the audit is FAILED
|
||||
"""
|
||||
audit = CourseResetAuditFactory.create(
|
||||
course=self.opt_in,
|
||||
course_enrollment=self.enrollment,
|
||||
status=audit_status,
|
||||
)
|
||||
self.assertResponse([{
|
||||
'course_id': self.course_id,
|
||||
'display_name': self.course.display_name,
|
||||
'can_reset': expected_can_reset,
|
||||
'status': audit.status_message()
|
||||
}])
|
||||
|
||||
def test_multiple_courses(self):
|
||||
""" Test for the behavior of multiple courses """
|
||||
courses = [CourseFactory.create(start=self.course.start, end=self.course.end) for _ in range(4)]
|
||||
for course in courses:
|
||||
CourseEnrollmentFactory.create(course_id=course.id, user=self.learner)
|
||||
CourseResetCourseOptInFactory.create(course_id=course.id)
|
||||
other_courses = [CourseFactory.create(start=self.course.start, end=self.course.end) for _ in range(4)]
|
||||
for course in other_courses:
|
||||
CourseEnrollmentFactory.create(course_id=course.id, user=self.learner)
|
||||
|
||||
expected_response = [{
|
||||
'course_id': self.course_id,
|
||||
'display_name': self.course.display_name,
|
||||
'can_reset': True,
|
||||
'status': 'Available'
|
||||
}]
|
||||
for course in courses:
|
||||
expected_response.append({
|
||||
'course_id': str(course.id),
|
||||
'display_name': course.display_name,
|
||||
'can_reset': True,
|
||||
'status': 'Available'
|
||||
})
|
||||
|
||||
self.assertResponse(expected_response)
|
||||
|
||||
def test_multiple_audits(self):
|
||||
"""
|
||||
If you have multiple audits for an enrollment (should only happen if process fails)
|
||||
the information returned should be for the most recent ONLY
|
||||
"""
|
||||
daysago = lambda x: self.now - timedelta(days=x)
|
||||
CourseResetAuditFactory.create(
|
||||
course=self.opt_in,
|
||||
course_enrollment=self.enrollment,
|
||||
status=CourseResetAudit.CourseResetStatus.FAILED,
|
||||
created=daysago(3),
|
||||
modified=daysago(3),
|
||||
)
|
||||
CourseResetAuditFactory.create(
|
||||
course=self.opt_in,
|
||||
course_enrollment=self.enrollment,
|
||||
status=CourseResetAudit.CourseResetStatus.FAILED,
|
||||
created=daysago(2),
|
||||
modified=daysago(2),
|
||||
)
|
||||
most_recent_audit = CourseResetAuditFactory.create(
|
||||
course=self.opt_in,
|
||||
course_enrollment=self.enrollment,
|
||||
status=CourseResetAudit.CourseResetStatus.IN_PROGRESS,
|
||||
)
|
||||
|
||||
response = self.assertResponse([{
|
||||
'course_id': self.course_id,
|
||||
'display_name': self.course.display_name,
|
||||
'can_reset': False,
|
||||
'status': most_recent_audit.status_message()
|
||||
}])
|
||||
|
||||
def test_multiple_failed_audits(self):
|
||||
"""
|
||||
If you have multiple audits for an enrollment and the most recent was a failure,
|
||||
you should still be able to reset the course
|
||||
"""
|
||||
daysago = lambda x: self.now - timedelta(days=x)
|
||||
CourseResetAuditFactory.create(
|
||||
course=self.opt_in,
|
||||
course_enrollment=self.enrollment,
|
||||
status=CourseResetAudit.CourseResetStatus.FAILED,
|
||||
created=daysago(3),
|
||||
modified=daysago(3),
|
||||
)
|
||||
CourseResetAuditFactory.create(
|
||||
course=self.opt_in,
|
||||
course_enrollment=self.enrollment,
|
||||
status=CourseResetAudit.CourseResetStatus.FAILED,
|
||||
created=daysago(2),
|
||||
modified=daysago(2),
|
||||
)
|
||||
most_recent_audit = CourseResetAuditFactory.create(
|
||||
course=self.opt_in,
|
||||
course_enrollment=self.enrollment,
|
||||
status=CourseResetAudit.CourseResetStatus.FAILED,
|
||||
)
|
||||
|
||||
response = self.assertResponse([{
|
||||
'course_id': self.course_id,
|
||||
'display_name': self.course.display_name,
|
||||
'can_reset': True,
|
||||
'status': most_recent_audit.status_message()
|
||||
}])
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.urls import path, re_path
|
||||
from .views.certificate import CertificatesSupportView
|
||||
from .views.contact_us import ContactUsView
|
||||
from .views.course_entitlements import EntitlementSupportView
|
||||
from .views.course_reset import CourseResetAPIView
|
||||
from .views.enrollments import EnrollmentSupportListView, EnrollmentSupportView
|
||||
from .views.feature_based_enrollments import FeatureBasedEnrollmentsSupportView, FeatureBasedEnrollmentSupportAPIView
|
||||
from .views.index import index
|
||||
@@ -24,6 +25,7 @@ from .views.sso_records import (
|
||||
)
|
||||
from .views.onboarding_status import OnboardingView
|
||||
|
||||
|
||||
COURSE_ENTITLEMENTS_VIEW = EntitlementSupportView.as_view()
|
||||
|
||||
app_name = 'support'
|
||||
@@ -84,4 +86,9 @@ urlpatterns = [
|
||||
r'onboarding_status/(?P<username_or_email>[\w.@+-]+)?$',
|
||||
OnboardingView.as_view(), name='onboarding_status'
|
||||
),
|
||||
re_path(
|
||||
r'course_reset/(?P<username_or_email>[\w.@+-]+)?$',
|
||||
CourseResetAPIView.as_view(),
|
||||
name='course_reset'
|
||||
),
|
||||
]
|
||||
|
||||
100
lms/djangoapps/support/views/course_reset.py
Normal file
100
lms/djangoapps/support/views/course_reset.py
Normal file
@@ -0,0 +1,100 @@
|
||||
""" Views for the course reset feature """
|
||||
|
||||
from rest_framework.response import Response
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.decorators import method_decorator
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
|
||||
from common.djangoapps.student.models import CourseEnrollment, get_user_by_username_or_email
|
||||
from common.djangoapps.student.helpers import user_has_passing_grade_in_course
|
||||
from lms.djangoapps.support.decorators import require_support_permission
|
||||
from lms.djangoapps.support.models import (
|
||||
CourseResetCourseOptIn,
|
||||
CourseResetAudit
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def can_enrollment_be_reset(course_enrollment):
|
||||
"""
|
||||
Args: enrollment (CourseEnrollment)
|
||||
Returns: tuple (boolean, string)
|
||||
[0]: whether or not the course can be reset
|
||||
[1]: a status message to present to the learner
|
||||
or None if there is nothing notable about the enrollment and it can be reset
|
||||
"""
|
||||
course_overview = course_enrollment.course_overview
|
||||
if not course_overview.has_started():
|
||||
return False, "Course Not Started"
|
||||
if course_overview.has_ended():
|
||||
return False, "Course Ended"
|
||||
if user_has_passing_grade_in_course(course_enrollment):
|
||||
return False, "Learner Has Passing Grade"
|
||||
|
||||
try:
|
||||
audit = course_enrollment.courseresetaudit_set.latest('modified')
|
||||
except CourseResetAudit.DoesNotExist:
|
||||
return True, None
|
||||
|
||||
audit_status_message = audit.status_message()
|
||||
if audit.status == CourseResetAudit.CourseResetStatus.FAILED:
|
||||
return True, audit_status_message
|
||||
return False, audit_status_message
|
||||
|
||||
|
||||
class CourseResetAPIView(APIView):
|
||||
"""
|
||||
A view to handle requests related to the course reset feature.
|
||||
GET: List applicable courses, their statuses, and if they can be reset
|
||||
POST: Reset a course for the given learner
|
||||
"""
|
||||
|
||||
permission_classes = (
|
||||
IsAuthenticated,
|
||||
)
|
||||
|
||||
@method_decorator(require_support_permission)
|
||||
def get(self, request, username_or_email):
|
||||
"""
|
||||
List the enrollments for this user that are in courses that have opted into the
|
||||
course reset feature, including information about past resets or resets in progress, and
|
||||
whether or not the reset will be allowed to be done for each returned enrollment
|
||||
|
||||
returns a list of dicts with the format [
|
||||
{
|
||||
'course_id': <course id>
|
||||
'display_name': <course display name>
|
||||
'status': <status of the enrollment wrt/reset, to be displayed to user>
|
||||
'can_reset': (boolean) <can the course be reset for this learner>
|
||||
}
|
||||
]
|
||||
"""
|
||||
try:
|
||||
user = get_user_by_username_or_email(username_or_email)
|
||||
except User.DoesNotExist:
|
||||
return Response([])
|
||||
all_enabled_resettable_course_ids = CourseResetCourseOptIn.all_active_course_ids()
|
||||
course_enrollments = CourseEnrollment.objects.filter(
|
||||
is_active=True,
|
||||
user=user,
|
||||
course__id__in=all_enabled_resettable_course_ids
|
||||
).select_related("course").prefetch_related("courseresetaudit_set")
|
||||
|
||||
result = []
|
||||
for course_enrollment in course_enrollments:
|
||||
course_overview = course_enrollment.course_overview
|
||||
can_reset, status_message = can_enrollment_be_reset(course_enrollment)
|
||||
result.append({
|
||||
'course_id': str(course_overview.id),
|
||||
'display_name': course_overview.display_name,
|
||||
'can_reset': can_reset,
|
||||
'status': status_message if status_message else "Available"
|
||||
})
|
||||
return Response(result)
|
||||
|
||||
@method_decorator(require_support_permission)
|
||||
def post(self, request, username_or_email):
|
||||
""" Other Ticket """
|
||||
Reference in New Issue
Block a user