Commit b89736c7 by J. Cliff Dyer

Add compute_grades management command.

TNL-6689
parent e8a9fd0f
"""
Command to compute all grades for specified courses.
"""
from __future__ import absolute_import, division, print_function, unicode_literals
import logging
from django.core.management.base import BaseCommand
import six
from openedx.core.lib.command_utils import (
get_mutually_exclusive_required_option,
parse_course_keys,
)
from student.models import CourseEnrollment
from xmodule.modulestore.django import modulestore
from ... import tasks
log = logging.getLogger(__name__)
class Command(BaseCommand):
"""
Example usage:
$ ./manage.py lms compute_grades --all_courses --settings=devstack
$ ./manage.py lms compute_grades 'edX/DemoX/Demo_Course' --settings=devstack
"""
args = '<course_id course_id ...>'
help = 'Computes grade values for all learners in specified courses.'
def add_arguments(self, parser):
"""
Entry point for subclassed commands to add custom arguments.
"""
parser.add_argument(
'--courses',
dest='courses',
nargs='+',
help='List of (space separated) courses that need grades computed.',
)
parser.add_argument(
'--all_courses',
help='Compute grades for all courses.',
action='store_true',
default=False,
)
parser.add_argument(
'--routing_key',
dest='routing_key',
help='Celery routing key to use.',
)
parser.add_argument(
'--batch_size',
help='Maximum number of students to calculate grades for, per celery task.',
default=100,
type=int,
)
parser.add_argument(
'--start_index',
help='Offset from which to start processing enrollments.',
default=0,
type=int,
)
def handle(self, *args, **options):
self._set_log_level(options)
for course_key in self._get_course_keys(options):
self.enqueue_compute_grades_for_course_tasks(course_key, options)
def enqueue_compute_grades_for_course_tasks(self, course_key, options):
"""
Enqueue celery tasks to compute and persist all grades for the
specified course, in batches.
"""
enrollment_count = CourseEnrollment.objects.filter(course_id=course_key).count()
if enrollment_count == 0:
log.warning("No enrollments found for {}".format(course_key))
for offset in six.moves.range(options['start_index'], enrollment_count, options['batch_size']):
# If the number of enrollments increases after the tasks are
# created, the most recent enrollments may not get processed.
# This is an acceptable limitation for our known use cases.
task_options = {'routing_key': options['routing_key']} if options.get('routing_key') else {}
kwargs = {
'course_key': six.text_type(course_key),
'offset': offset,
'batch_size': options['batch_size'],
}
result = tasks.compute_grades_for_course.apply_async(
kwargs=kwargs,
options=task_options,
)
log.info("Persistent grades: Created {task_name}[{task_id}] with arguments {kwargs}".format(
task_name=tasks.compute_grades_for_course.name,
task_id=result.task_id,
kwargs=kwargs,
))
def _get_course_keys(self, options):
"""
Return a list of courses that need scores computed.
"""
courses_mode = get_mutually_exclusive_required_option(options, 'courses', 'all_courses')
if courses_mode == 'all_courses':
course_keys = [course.id for course in modulestore().get_course_summaries()]
else:
course_keys = parse_course_keys(options['courses'])
return course_keys
def _set_log_level(self, options):
"""
Sets logging levels for this module and the block structure
cache module, based on the given the options.
"""
if options.get('verbosity') == 0:
log_level = logging.WARNING
elif options.get('verbosity') >= 1:
log_level = logging.INFO
log.setLevel(log_level)
"""
Tests for compute_grades management command.
"""
# pylint: disable=protected-access
from __future__ import absolute_import, division, print_function, unicode_literals
import ddt
from django.contrib.auth import get_user_model
from django.core.management import CommandError, call_command
from mock import patch
import six
from student.models import CourseEnrollment
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from lms.djangoapps.grades.management.commands import compute_grades
@ddt.ddt
class TestComputeGrades(SharedModuleStoreTestCase):
"""
Tests compute_grades management command.
"""
num_users = 3
num_courses = 5
@classmethod
def setUpClass(cls):
super(TestComputeGrades, cls).setUpClass()
User = get_user_model() # pylint: disable=invalid-name
cls.command = compute_grades.Command()
cls.courses = [CourseFactory.create() for _ in range(cls.num_courses)]
cls.course_keys = [six.text_type(course.id) for course in cls.courses]
cls.users = [User.objects.create(username='user{}'.format(idx)) for idx in range(cls.num_users)]
for user in cls.users:
for course in cls.courses:
CourseEnrollment.enroll(user, course.id)
def test_select_all_courses(self):
courses = self.command._get_course_keys({'all_courses': True})
self.assertEqual(
sorted(six.text_type(course) for course in courses),
self.course_keys,
)
def test_specify_courses(self):
courses = self.command._get_course_keys({'courses': [self.course_keys[0], self.course_keys[1], 'd/n/e']})
self.assertEqual(
[six.text_type(course) for course in courses],
[self.course_keys[0], self.course_keys[1], 'd/n/e'],
)
def test_selecting_invalid_course(self):
with self.assertRaises(CommandError):
self.command._get_course_keys({'courses': [self.course_keys[0], self.course_keys[1], 'badcoursekey']})
@patch('lms.djangoapps.grades.tasks.compute_grades_for_course')
def test_tasks_fired(self, mock_task):
call_command(
'compute_grades',
'--routing_key=key',
'--batch_size=2',
'--courses',
self.course_keys[0],
self.course_keys[3],
'd/n/e' # No tasks created for nonexistent course, because it has no enrollments
)
self.assertEqual(
mock_task.apply_async.call_args_list,
[
({
'options': {'routing_key': 'key'},
'kwargs': {'course_key': self.course_keys[0], 'batch_size': 2, 'offset': 0}
},),
({
'options': {'routing_key': 'key'},
'kwargs': {'course_key': self.course_keys[0], 'batch_size': 2, 'offset': 2}
},),
({
'options': {'routing_key': 'key'},
'kwargs': {'course_key': self.course_keys[3], 'batch_size': 2, 'offset': 0}
},),
({
'options': {'routing_key': 'key'},
'kwargs': {'course_key': self.course_keys[3], 'batch_size': 2, 'offset': 2}
},),
],
)
......@@ -302,17 +302,17 @@ class TestResetGrades(TestCase):
self.command.handle(delete=True, all_courses=True, db_table="not course or subsection")
def test_no_run_mode(self):
with self.assertRaisesMessage(CommandError, 'Either --delete or --dry_run must be specified.'):
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --delete, --dry_run'):
self.command.handle(all_courses=True)
def test_both_run_modes(self):
with self.assertRaisesMessage(CommandError, 'Both --delete and --dry_run cannot be specified.'):
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --delete, --dry_run'):
self.command.handle(all_courses=True, dry_run=True, delete=True)
def test_no_course_mode(self):
with self.assertRaisesMessage(CommandError, 'Either --courses or --all_courses must be specified.'):
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --courses, --all_courses'):
self.command.handle(dry_run=True)
def test_both_course_modes(self):
with self.assertRaisesMessage(CommandError, 'Both --courses and --all_courses cannot be specified.'):
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --courses, --all_courses'):
self.command.handle(dry_run=True, all_courses=True, courses=['some/course/key'])
......@@ -3,7 +3,6 @@ This module contains tasks for asynchronous execution of grade updates.
"""
from celery import task
from celery.exceptions import Retry
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
......@@ -56,6 +55,14 @@ class _BaseTask(PersistOnFailureTask, LoggedTask): # pylint: disable=abstract-m
abstract = True
@task
def compute_grades_for_course(course_key, offset, batch_size): # pylint: disable=unused-argument
"""
TODO: TNL-6690: Fill this task in and remove pylint disables
"""
pass
@task(bind=True, base=_BaseTask, default_retry_delay=30, routing_key=settings.RECALCULATE_GRADES_ROUTING_KEY)
def recalculate_subsection_grade_v3(self, **kwargs):
"""
......
......@@ -158,11 +158,11 @@ class TestGenerateCourseBlocks(ModuleStoreTestCase):
self.command.handle(all_courses=False)
def test_no_course_mode(self):
with self.assertRaisesMessage(CommandError, 'Either --courses or --all_courses must be specified.'):
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --courses, --all_courses'):
self.command.handle()
def test_both_course_modes(self):
with self.assertRaisesMessage(CommandError, 'Both --courses and --all_courses cannot be specified.'):
with self.assertRaisesMessage(CommandError, 'Must specify exactly one of --courses, --all_courses'):
self.command.handle(all_courses=True, courses=['some/course/key'])
@ddt.data(
......
......@@ -8,17 +8,18 @@ from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
def get_mutually_exclusive_required_option(options, option_1, option_2):
def get_mutually_exclusive_required_option(options, *selections):
"""
Validates that exactly one of the 2 given options is specified.
Returns the name of the found option.
"""
validate_mutually_exclusive_option(options, option_1, option_2)
if not options.get(option_1) and not options.get(option_2):
raise CommandError('Either --{} or --{} must be specified.'.format(option_1, option_2))
selected = [sel for sel in selections if options.get(sel)]
if len(selected) != 1:
selection_string = u', '.join('--{}'.format(selection) for selection in selections)
return option_1 if options.get(option_1) else option_2
raise CommandError(u'Must specify exactly one of {}'.format(selection_string))
return selected[0]
def validate_mutually_exclusive_option(options, option_1, option_2):
......
"""
Tests of management command utility code
"""
from unittest import TestCase
import ddt
from django.core.management import CommandError
from .. import command_utils
@ddt.ddt
class MutuallyExclusiveRequiredOptionsTestCase(TestCase):
"""
Test that mutually exclusive required options allow one and only one option
to be specified with a true value.
"""
@ddt.data(
(['opta'], {'opta': 1}, 'opta'),
(['opta', 'optb'], {'opta': 1}, 'opta'),
(['opta', 'optb'], {'optb': 1}, 'optb'),
(['opta', 'optb'], {'opta': 1, 'optc': 1}, 'opta'),
(['opta', 'optb'], {'opta': 1, 'optb': 0}, 'opta'),
(['opta', 'optb', 'optc'], {'optc': 1, 'optd': 1}, 'optc'),
(['opta', 'optb', 'optc'], {'optc': 1}, 'optc'),
(['opta', 'optb', 'optc'], {'optd': 0, 'optc': 1}, 'optc'),
)
@ddt.unpack
def test_successful_exclusive_options(self, exclusions, opts, expected):
result = command_utils.get_mutually_exclusive_required_option(opts, *exclusions)
self.assertEqual(result, expected)
@ddt.data(
(['opta'], {'opta': 0}),
(['opta', 'optb'], {'opta': 1, 'optb': 1}),
(['opta', 'optb'], {'optc': 1, 'optd': 1}),
(['opta', 'optb'], {}),
(['opta', 'optb', 'optc'], {'opta': 1, 'optc': 1}),
(['opta', 'optb', 'optc'], {'opta': 1, 'optb': 1}),
(['opta', 'optb', 'optc'], {'optb': 1, 'optc': 1}),
(['opta', 'optb', 'optc'], {'opta': 1, 'optb': 1, 'optc': 1}),
(['opta', 'optb', 'optc'], {}),
)
@ddt.unpack
def test_invalid_exclusive_options(self, exclusions, opts):
with self.assertRaises(CommandError):
command_utils.get_mutually_exclusive_required_option(opts, *exclusions)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment