Skip to content

Commit

Permalink
Merge pull request #978 from edx/alangsto/allow_practice_exams_to_tri…
Browse files Browse the repository at this point in the history
…gger_grades

fix: practice exam updates should trigger grade and credit updates
  • Loading branch information
alangsto authored Oct 18, 2021
2 parents a3e1884 + 3955e3a commit 058c9ab
Show file tree
Hide file tree
Showing 7 changed files with 223 additions and 5 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ Change Log
Unreleased
~~~~~~~~~~

[4.1.3] - 2021-10-15
~~~~~~~~~~~~~~~~~~~~
* Always allow practice attempts to trigger grade/credit/certificate updates

[4.1.2] - 2021-10-07
~~~~~~~~~~~~~~~~~~~~
* Instructor dashboard view should redirect to review url for PSI exam attempts
Expand Down
2 changes: 1 addition & 1 deletion edx_proctoring/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"""

# Be sure to update the version number in edx_proctoring/package.json
__version__ = '4.1.2'
__version__ = '4.1.3'

default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name
10 changes: 7 additions & 3 deletions edx_proctoring/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,6 @@ def create_exam(course_id, content_id, exam_name, time_limit_mins, due_date=None
'is_proctored': is_proctored,
'is_practice_exam': is_practice_exam,
'external_id': external_id,
'is_active': is_active,
'hide_after_due': hide_after_due,
}
)
Expand Down Expand Up @@ -1271,14 +1270,19 @@ def is_state_transition_legal(from_status, to_status, attempt_obj):
return True


def can_update_credit_grades_and_email(attempts, to_status):
def can_update_credit_grades_and_email(attempts, to_status, exam):
"""
Determine and return as a boolean whether an attempt should trigger an update to credit and grades
Arguments:
attempts: a list of all currently active attempts for a given user_id and exam_id
to_status: future status of a proctored exam attempt
exam: dict representation of exam object
"""
# if the exam is a practice exam, always allow updates
if exam['is_practice_exam']:
return True

statuses = [attempt['status'] for attempt in attempts]
if len(statuses) == 1:
# if there is only one attempt for a user in an exam, it can be responsible for updates to credits and grades
Expand Down Expand Up @@ -1426,7 +1430,7 @@ def update_attempt_status(attempt_id, to_status,

all_attempts = get_user_attempts_by_exam_id(user_id, exam_id)

if can_update_credit_grades_and_email(all_attempts, to_status):
if can_update_credit_grades_and_email(all_attempts, to_status, exam):

# see if the status transition this changes credit requirement status
if ProctoredExamStudentAttemptStatus.needs_credit_status_update(to_status):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""
Tests for the update_attempts_for_exam management command
"""

from mock import patch

from django.contrib.auth import get_user_model
from django.core.management import call_command

from edx_proctoring.api import create_exam, create_exam_attempt, update_attempt_status
from edx_proctoring.models import ProctoredExamStudentAttempt
from edx_proctoring.runtime import set_runtime_service
from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus
from edx_proctoring.tests.test_services import MockCertificateService, MockCreditService, MockGradesService
from edx_proctoring.tests.utils import LoggedInTestCase

User = get_user_model()


class TestUpdateAttemptsForExam(LoggedInTestCase):
"""
Coverage of the update_attempts_for_exam.py file
"""

def setUp(self):
"""
Build up test data
"""
super().setUp()
set_runtime_service('credit', MockCreditService())
set_runtime_service('grades', MockGradesService())
set_runtime_service('certificates', MockCertificateService())

def test_run_command(self):
"""
Run the management command
"""
exam_id = create_exam(
course_id='foo',
content_id='bar',
exam_name='Test Exam 1',
time_limit_mins=90
)

# create three users and three exam attempts
for i in range(3):
other_user = User.objects.create(username='otheruser'+str(i), password='test')
attempt_id = create_exam_attempt(exam_id, other_user.id, taking_as_proctored=True)
update_attempt_status(attempt_id, ProctoredExamStudentAttemptStatus.verified)

with patch.object(MockCreditService, 'set_credit_requirement_status') as mock_credit:
call_command(
'update_attempts_for_exam',
batch_size=2,
sleep_time=0,
exam_id=exam_id
)
mock_credit.assert_called()

# make sure status stays the same
attempts = ProctoredExamStudentAttempt.objects.filter(status=ProctoredExamStudentAttemptStatus.verified)
self.assertEqual(len(attempts), 3)
78 changes: 78 additions & 0 deletions edx_proctoring/management/commands/update_attempts_for_exam.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""
Django management command to re-trigger the status update of a ProctoredExamStudentAttempt
for a specific proctored exam.
"""

import logging
import time

from django.core.management.base import BaseCommand

from edx_proctoring.api import update_attempt_status
from edx_proctoring.models import ProctoredExamStudentAttempt

log = logging.getLogger(__name__)


class Command(BaseCommand):
"""
Django Management command to update is_attempt_active field on review models
"""

def add_arguments(self, parser):
parser.add_argument(
'--batch_size',
action='store',
dest='batch_size',
type=int,
default=300,
help='Maximum number of attempts to process. '
'This helps avoid overloading the database while updating large amount of data.'
)
parser.add_argument(
'--sleep_time',
action='store',
dest='sleep_time',
type=int,
default=10,
help='Sleep time in seconds between update of batches'
)

parser.add_argument(
'--exam_id',
action='store',
dest='exam_id',
type=int,
help='Exam ID to process attempts for.'
)

def handle(self, *args, **options):
"""
Management command entry point, simply call into the signal firing
"""

batch_size = options['batch_size']
sleep_time = options['sleep_time']
exam_id = options['exam_id']

# get all attempts for specific exam id
exam_attempts = ProctoredExamStudentAttempt.objects.filter(proctored_exam_id=exam_id)

attempt_count = 0

# for each of those attempts, get id and status
for attempt in exam_attempts:
current_status = attempt.status
current_id = attempt.id

log.info(
'Triggering attempt status update for attempt_id=%(attempt_id)s with status=%(status)s',
{'attempt_id': current_id, 'status': current_status}
)
# need to use update_attempt_status because this function will trigger grade + credit updates
update_attempt_status(current_id, current_status)
attempt_count += 1

if attempt_count == batch_size:
attempt_count = 0
time.sleep(sleep_time)
70 changes: 70 additions & 0 deletions edx_proctoring/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3279,6 +3279,76 @@ def test_grade_certificate_release_with_multiple_attempts(
else:
self.assertEqual(override, None)

def test_grade_certificate_override_practice_exam(self):
"""
Test that if a user has multiple attempts in a practice exam, grades/certificates/emails will
be updated for each attempt status update.
"""
set_runtime_service('grades', MockGradesService())
# create first attempt, and reset attempt
first_attempt = self._create_exam_attempt(
self.onboarding_exam_id,
status=ProctoredExamStudentAttemptStatus.error,
is_practice_exam=True,
)
reset_practice_exam(self.onboarding_exam_id, self.user_id, self.user)
first_attempt.refresh_from_db()
self.assertEqual(first_attempt.status, ProctoredExamStudentAttemptStatus.onboarding_reset)
# that should create a second attempt, set second attempt to rejected
second_attempt = ProctoredExamStudentAttempt.objects.get_current_exam_attempt(
self.onboarding_exam_id, self.user.id
)

credit_service = get_runtime_service('credit')
grades_service = get_runtime_service('grades')
content_id = first_attempt.proctored_exam.content_id

grades_service.init_grade(
user_id=self.user.id,
course_key_or_id=self.course_id,
usage_key_or_id=content_id,
earned_all=5.0,
earned_graded=5.0
)

# set status to rejected, credit should be failed, email should be sent,
# grades should have override
update_attempt_status(second_attempt.id, ProctoredExamStudentAttemptStatus.rejected)
credit_status = credit_service.get_credit_state(self.user.id, self.course_id)
self.assertEqual(len(credit_status['credit_requirement_status']), 1)
self.assertEqual(
credit_status['credit_requirement_status'][0]['status'],
'failed'
)
override = grades_service.get_subsection_grade_override(
user_id=self.user.id,
course_key_or_id=self.course_id,
usage_key_or_id=content_id
)
self.assertDictEqual({
'earned_all': override.earned_all_override,
'earned_graded': override.earned_graded_override
}, {
'earned_all': 0.0,
'earned_graded': 0.0
})

# set status to verified, credit should be satisfied, email should be sent,
# grades should not have override
update_attempt_status(second_attempt.id, ProctoredExamStudentAttemptStatus.verified)
credit_status = credit_service.get_credit_state(self.user.id, self.course_id)
self.assertEqual(len(credit_status['credit_requirement_status']), 1)
self.assertEqual(
credit_status['credit_requirement_status'][0]['status'],
'satisfied'
)
override = grades_service.get_subsection_grade_override(
user_id=self.user.id,
course_key_or_id=self.course_id,
usage_key_or_id=content_id
)
self.assertEqual(override, None)

def test_create_exam_attempt_empty_string(self):
"""
Assert that exam attempt creation does not fail if the user's profile name is an
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@edx/edx-proctoring",
"//": "Note that the version format is slightly different than that of the Python version when using prereleases.",
"version": "4.1.2",
"version": "4.1.3",
"main": "edx_proctoring/static/index.js",
"scripts": {
"test": "gulp test"
Expand Down

0 comments on commit 058c9ab

Please sign in to comment.