Files
edx-platform/lms/djangoapps/instructor/tests/test_services.py
Dillon Dumesnil 05afe78552 fix: AA-1063: Reset Completion data for problems on exam reset
We encountered a bug where learners were sometimes not having their
completion information reset when their exam is reset. It was unclear
what was actually causing the completion to not be reset (it usually
is via a signal listener), but the effect was learners being unable to
reset their due dates in order to attempt the exam again since the exam
believed it was still complete.

This PR will likely be duplicating calls to the Completion API, but we
believe that is worthwhile to ensure successful completion state reset.
2021-10-27 09:42:52 -04:00

335 lines
14 KiB
Python

"""
Tests for the InstructorService
"""
import json
from unittest import mock
import pytest
from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH
from django.core.exceptions import ObjectDoesNotExist
from edx_toggles.toggles.testutils import override_waffle_switch
from opaque_keys import InvalidKeyError
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.courseware.models import StudentModule
from lms.djangoapps.instructor.access import allow_access
from lms.djangoapps.instructor.services import InstructorService
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.partitions.partitions import Group, UserPartition
class InstructorServiceTests(SharedModuleStoreTestCase):
"""
Tests for the InstructorService
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.email = 'escalation@test.com'
cls.course = CourseFactory.create(proctoring_escalation_email=cls.email)
cls.section = ItemFactory.create(parent=cls.course, category='chapter')
cls.subsection = ItemFactory.create(parent=cls.section, category='sequential')
cls.unit = ItemFactory.create(parent=cls.subsection, category='vertical')
cls.problem = ItemFactory.create(parent=cls.unit, category='problem')
cls.unit_2 = ItemFactory.create(parent=cls.subsection, category='vertical')
cls.problem_2 = ItemFactory.create(parent=cls.unit_2, category='problem')
cls.complete_error_prefix = ('Error occurred while attempting to complete student attempt for '
'user {user} for content_id {content_id}. ')
def setUp(self):
super().setUp()
self.student = UserFactory()
CourseEnrollment.enroll(self.student, self.course.id)
self.service = InstructorService()
self.module_to_reset = StudentModule.objects.create(
student=self.student,
course_id=self.course.id,
module_state_key=self.problem.location,
state=json.dumps({'attempts': 2}),
)
@mock.patch('lms.djangoapps.grades.signals.handlers.PROBLEM_WEIGHTED_SCORE_CHANGED.send')
@mock.patch('completion.handlers.BlockCompletion.objects.submit_completion')
def test_reset_student_attempts_delete(self, mock_submit, _mock_signal):
"""
Test delete student state.
"""
# make sure the attempt is there
assert StudentModule.objects.filter(student=self.module_to_reset.student, course_id=self.course.id,
module_state_key=self.module_to_reset.module_state_key).count() == 1
with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True):
self.service.delete_student_attempt(
self.student.username,
str(self.course.id),
str(self.subsection.location),
requesting_user=self.student,
)
# make sure the module has been deleted
assert StudentModule.objects.filter(student=self.module_to_reset.student, course_id=self.course.id,
module_state_key=self.module_to_reset.module_state_key).count() == 0
# Assert we send completion == 0.0 for both problems even though the second problem was never viewed
assert mock_submit.call_count == 2
mock_submit.assert_any_call(user=self.student, block_key=self.problem.location, completion=0.0)
mock_submit.assert_any_call(user=self.student, block_key=self.problem_2.location, completion=0.0)
def test_reset_bad_content_id(self):
"""
Negative test of trying to reset attempts with bad content_id
"""
result = self.service.delete_student_attempt( # lint-amnesty, pylint: disable=assignment-from-none
self.student.username,
str(self.course.id),
'foo/bar/baz',
requesting_user=self.student,
)
assert result is None
def test_reset_bad_user(self):
"""
Negative test of trying to reset attempts with bad user identifier
"""
result = self.service.delete_student_attempt( # lint-amnesty, pylint: disable=assignment-from-none
'bad_student',
str(self.course.id),
'foo/bar/baz',
requesting_user=self.student,
)
assert result is None
def test_reset_non_existing_attempt(self):
"""
Negative test of trying to reset attempts with bad user identifier
"""
result = self.service.delete_student_attempt( # lint-amnesty, pylint: disable=assignment-from-none
self.student.username,
str(self.course.id),
str(self.problem_2.location),
requesting_user=self.student,
)
assert result is None
@mock.patch('completion.handlers.BlockCompletion.objects.submit_completion')
def test_complete_student_attempt_success(self, mock_submit):
"""
Assert complete_student_attempt correctly publishes completion for all
completable children of the given content_id
"""
# Section, subsection, and unit are all aggregators and not completable so should
# not be submitted.
section = ItemFactory.create(parent=self.course, category='chapter')
subsection = ItemFactory.create(parent=section, category='sequential')
unit = ItemFactory.create(parent=subsection, category='vertical')
# should both be submitted
video = ItemFactory.create(parent=unit, category='video')
problem = ItemFactory.create(parent=unit, category='problem')
# Not a completable block
ItemFactory.create(parent=unit, category='discussion')
with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True):
self.service.complete_student_attempt(self.student.username, str(subsection.location))
# Only Completable leaf blocks should have completion published
assert mock_submit.call_count == 2
mock_submit.assert_any_call(user=self.student, block_key=video.location, completion=1.0)
mock_submit.assert_any_call(user=self.student, block_key=problem.location, completion=1.0)
@mock.patch('completion.handlers.BlockCompletion.objects.submit_completion')
def test_complete_student_attempt_split_test(self, mock_submit):
"""
Asserts complete_student_attempt correctly publishes completion when a split test is involved
This test case exists because we ran into a bug about the user_service not existing
when a split_test existed inside of a subsection. Associated with this change was adding
in the user state into the module before attempting completion and this ensures that is
working properly.
"""
partition = UserPartition(
0,
'first_partition',
'First Partition',
[
Group(0, 'alpha'),
Group(1, 'beta')
]
)
course = CourseFactory.create(user_partitions=[partition])
section = ItemFactory.create(parent=course, category='chapter')
subsection = ItemFactory.create(parent=section, category='sequential')
c0_url = course.id.make_usage_key('vertical', 'split_test_cond0')
c1_url = course.id.make_usage_key('vertical', 'split_test_cond1')
split_test = ItemFactory.create(
parent=subsection,
category='split_test',
user_partition_id=0,
group_id_to_child={'0': c0_url, '1': c1_url},
)
cond0vert = ItemFactory.create(parent=split_test, category='vertical', location=c0_url)
ItemFactory.create(parent=cond0vert, category='video')
ItemFactory.create(parent=cond0vert, category='problem')
cond1vert = ItemFactory.create(parent=split_test, category='vertical', location=c1_url)
ItemFactory.create(parent=cond1vert, category='video')
ItemFactory.create(parent=cond1vert, category='html')
with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True):
self.service.complete_student_attempt(self.student.username, str(subsection.location))
# Only the group the user was assigned to should have completion published.
# Either cond0vert's children or cond1vert's children
assert mock_submit.call_count == 2
@mock.patch('lms.djangoapps.instructor.tasks.log.error')
def test_complete_student_attempt_bad_user(self, mock_logger):
"""
Assert complete_student_attempt with a bad user raises error and returns None
"""
username = 'bad_user'
block_id = str(self.problem.location)
self.service.complete_student_attempt(username, block_id)
mock_logger.assert_called_once_with(
self.complete_error_prefix.format(user=username, content_id=block_id) + 'User does not exist!'
)
@mock.patch('lms.djangoapps.instructor.tasks.log.error')
def test_complete_student_attempt_bad_content_id(self, mock_logger):
"""
Assert complete_student_attempt with a bad content_id raises error and returns None
"""
username = self.student.username
self.service.complete_student_attempt(username, 'foo/bar/baz')
mock_logger.assert_called_once_with(
self.complete_error_prefix.format(user=username, content_id='foo/bar/baz') + 'Invalid content_id!'
)
@mock.patch('lms.djangoapps.instructor.tasks.log.error')
def test_complete_student_attempt_nonexisting_item(self, mock_logger):
"""
Assert complete_student_attempt with nonexisting item in the modulestore
raises error and returns None
"""
username = self.student.username
block = 'i4x://org.0/course_0/problem/fake_problem'
self.service.complete_student_attempt(username, block)
mock_logger.assert_called_once_with(
self.complete_error_prefix.format(user=username, content_id=block) + 'Block not found in the modulestore!'
)
@mock.patch('lms.djangoapps.instructor.tasks.log.error')
def test_complete_student_attempt_failed_module(self, mock_logger):
"""
Assert complete_student_attempt with failed get_module raises error and returns None
"""
username = self.student.username
with mock.patch('lms.djangoapps.instructor.tasks.get_module_for_descriptor', return_value=None):
self.service.complete_student_attempt(username, str(self.course.location))
mock_logger.assert_called_once_with(
self.complete_error_prefix.format(user=username, content_id=self.course.location) +
'Module unable to be created from descriptor!'
)
def test_is_user_staff(self):
"""
Test to assert that the user is staff or not
"""
result = self.service.is_course_staff(
self.student,
str(self.course.id)
)
assert not result
# allow staff access to the student
allow_access(self.course, self.student, 'staff')
result = self.service.is_course_staff(
self.student,
str(self.course.id)
)
assert result
def test_report_suspicious_attempt(self):
"""
Test to verify that the create_zendesk_ticket() is called
"""
requester_name = "edx-proctoring"
email = "edx-proctoring@edx.org"
subject = "Proctored Exam Review: {review_status}".format(review_status="Suspicious")
body = "A proctored exam attempt for {exam_name} in {course_name} by username: {student_username} was " \
"reviewed as {review_status} by the proctored exam review provider.\n" \
"Review link: {url}"
args = {
'exam_name': 'test_exam',
'student_username': 'test_student',
'url': 'not available',
'course_name': self.course.display_name,
'review_status': 'Suspicious',
}
expected_body = body.format(**args)
tags = ["proctoring"]
with mock.patch("lms.djangoapps.instructor.services.create_zendesk_ticket") as mock_create_zendesk_ticket:
self.service.send_support_notification(
course_id=str(self.course.id),
exam_name=args['exam_name'],
student_username=args["student_username"],
review_status="Suspicious",
review_url=None,
)
mock_create_zendesk_ticket.assert_called_with(requester_name, email, subject, expected_body, tags)
# Now check sending a notification with a review link
args['url'] = 'http://review/url'
with mock.patch("lms.djangoapps.instructor.services.create_zendesk_ticket") as mock_create_zendesk_ticket:
self.service.send_support_notification(
course_id=str(self.course.id),
exam_name=args['exam_name'],
student_username=args["student_username"],
review_status="Suspicious",
review_url=args['url'],
)
expected_body = body.format(**args)
mock_create_zendesk_ticket.assert_called_with(requester_name, email, subject, expected_body, tags)
def test_get_proctoring_escalation_email_from_course_key(self):
"""
Test that it returns the correct proctoring escalation email from a course key object
"""
email = self.service.get_proctoring_escalation_email(self.course.id)
assert email == self.email
def test_get_proctoring_escalation_email_from_course_id(self):
"""
Test that it returns the correct proctoring escalation email from a course id string
"""
email = self.service.get_proctoring_escalation_email(str(self.course.id))
assert email == self.email
def test_get_proctoring_escalation_email_no_course(self):
"""
Test that it raises an exception if the course is not found
"""
with pytest.raises(ObjectDoesNotExist):
self.service.get_proctoring_escalation_email('a/b/c')
def test_get_proctoring_escalation_email_invalid_key(self):
"""
Test that it raises an exception if the course_key is invalid
"""
with pytest.raises(InvalidKeyError):
self.service.get_proctoring_escalation_email('invalid key')