Merge pull request #16548 from edx/jeskew/more_mgmt_cmd_conv_from_optparse
Move more mgmt commands from optparse to argparse.
This commit is contained in:
@@ -6,9 +6,9 @@ from django.db import transaction
|
|||||||
from opaque_keys import InvalidKeyError
|
from opaque_keys import InvalidKeyError
|
||||||
from opaque_keys.edx.keys import CourseKey
|
from opaque_keys.edx.keys import CourseKey
|
||||||
from six import text_type
|
from six import text_type
|
||||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
|
||||||
|
|
||||||
from course_modes.models import CourseMode
|
from course_modes.models import CourseMode
|
||||||
|
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||||
from student.models import CourseEnrollment
|
from student.models import CourseEnrollment
|
||||||
from xmodule.modulestore.django import modulestore
|
from xmodule.modulestore.django import modulestore
|
||||||
|
|
||||||
@@ -16,7 +16,9 @@ logger = logging.getLogger(__name__) # pylint: disable=invalid-name
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""Management command to change many user enrollments at once."""
|
"""
|
||||||
|
Management command to change many user enrollments at once.
|
||||||
|
"""
|
||||||
|
|
||||||
help = """
|
help = """
|
||||||
Change the enrollment status for all users enrolled in a
|
Change the enrollment status for all users enrolled in a
|
||||||
|
|||||||
@@ -2,16 +2,14 @@
|
|||||||
Django Management Command: Generate Course Structure
|
Django Management Command: Generate Course Structure
|
||||||
Generates and stores course structure information for one or more courses.
|
Generates and stores course structure information for one or more courses.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from optparse import make_option
|
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from opaque_keys.edx.keys import CourseKey
|
from opaque_keys.edx.keys import CourseKey
|
||||||
from xmodule.modulestore.django import modulestore
|
from six import text_type
|
||||||
|
|
||||||
from openedx.core.djangoapps.content.course_structures.tasks import update_course_structure
|
from openedx.core.djangoapps.content.course_structures.tasks import update_course_structure
|
||||||
|
from xmodule.modulestore.django import modulestore
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -20,15 +18,13 @@ class Command(BaseCommand):
|
|||||||
"""
|
"""
|
||||||
Generates and stores course structure information for one or more courses.
|
Generates and stores course structure information for one or more courses.
|
||||||
"""
|
"""
|
||||||
args = '<course_id course_id ...>'
|
|
||||||
help = 'Generates and stores course structure for one or more courses.'
|
help = 'Generates and stores course structure for one or more courses.'
|
||||||
|
|
||||||
option_list = BaseCommand.option_list + (
|
def add_arguments(self, parser):
|
||||||
make_option('--all',
|
parser.add_argument('course_id', nargs='*')
|
||||||
action='store_true',
|
parser.add_argument('--all',
|
||||||
default=False,
|
action='store_true',
|
||||||
help='Generate structures for all courses.'),
|
help='Generate structures for all courses.')
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
"""
|
"""
|
||||||
@@ -37,7 +33,7 @@ class Command(BaseCommand):
|
|||||||
if options['all']:
|
if options['all']:
|
||||||
course_keys = [course.id for course in modulestore().get_courses()]
|
course_keys = [course.id for course in modulestore().get_courses()]
|
||||||
else:
|
else:
|
||||||
course_keys = [CourseKey.from_string(arg) for arg in args]
|
course_keys = [CourseKey.from_string(arg) for arg in options['course_id']]
|
||||||
|
|
||||||
if not course_keys:
|
if not course_keys:
|
||||||
log.fatal('No courses specified.')
|
log.fatal('No courses specified.')
|
||||||
@@ -52,9 +48,9 @@ class Command(BaseCommand):
|
|||||||
# TODO Future improvement: Use .delay(), add return value to ResultSet, and wait for execution of
|
# TODO Future improvement: Use .delay(), add return value to ResultSet, and wait for execution of
|
||||||
# all tasks using ResultSet.join(). I (clintonb) am opting not to make this improvement right now
|
# all tasks using ResultSet.join(). I (clintonb) am opting not to make this improvement right now
|
||||||
# as I do not have time to test it fully.
|
# as I do not have time to test it fully.
|
||||||
update_course_structure.apply(args=[unicode(course_key)])
|
update_course_structure.apply(args=[text_type(course_key)])
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log.exception('An error occurred while generating course structure for %s: %s',
|
log.exception('An error occurred while generating course structure for %s: %s',
|
||||||
unicode(course_key), ex.message)
|
text_type(course_key), ex.message)
|
||||||
|
|
||||||
log.info('Finished generating course structures.')
|
log.info('Finished generating course structures.')
|
||||||
|
|||||||
@@ -19,22 +19,23 @@ When reports are generated, we need to handle:
|
|||||||
The command will always use the read replica database if one is configured.
|
The command will always use the read replica database if one is configured.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import os.path
|
from __future__ import unicode_literals
|
||||||
import csv
|
|
||||||
import time
|
import contextlib
|
||||||
import datetime
|
import csv
|
||||||
import contextlib
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import optparse
|
import os.path
|
||||||
|
import time
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.db import connections
|
from django.db import connections
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from opaque_keys.edx.keys import CourseKey
|
from opaque_keys.edx.keys import CourseKey
|
||||||
from xmodule.modulestore.django import modulestore
|
from six import text_type
|
||||||
|
|
||||||
|
from xmodule.modulestore.django import modulestore
|
||||||
|
|
||||||
DEFAULT_CHUNK_SIZE = 10
|
DEFAULT_CHUNK_SIZE = 10
|
||||||
|
|
||||||
@@ -46,20 +47,28 @@ def chunks(sequence, chunk_size):
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""Generate a list of email opt-in values for user enrollments. """
|
"""
|
||||||
|
Generate a list of email opt-in values for user enrollments.
|
||||||
args = "<OUTPUT_FILENAME> <ORG_ALIASES> --courses=COURSE_ID_LIST --email-optin-chunk-size=CHUNK_SIZE"
|
"""
|
||||||
help = "Generate a list of email opt-in values for user enrollments."
|
help = "Generate a list of email opt-in values for user enrollments."
|
||||||
option_list = BaseCommand.option_list + (
|
|
||||||
optparse.make_option('--courses ', action='store'),
|
def add_arguments(self, parser):
|
||||||
optparse.make_option(
|
parser.add_argument('file_path',
|
||||||
'--email-optin-chunk-size',
|
metavar='OUTPUT_FILENAME',
|
||||||
action='store',
|
help='Path where to output the email opt-in list.')
|
||||||
type='int',
|
parser.add_argument('org_list',
|
||||||
default=DEFAULT_CHUNK_SIZE,
|
nargs='+',
|
||||||
dest='email_optin_chunk_size',
|
metavar='ORG_ALIASES',
|
||||||
help='The number of courses to get opt-in information for in a single query.')
|
help='List of orgs for which to retrieve email opt-in info.')
|
||||||
)
|
parser.add_argument('--courses',
|
||||||
|
metavar='COURSE_ID_LIST',
|
||||||
|
help='List of course IDs for which to retrieve email opt-in info.')
|
||||||
|
parser.add_argument('--email-optin-chunk-size',
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_CHUNK_SIZE,
|
||||||
|
dest='email_optin_chunk_size',
|
||||||
|
metavar='CHUNK_SIZE',
|
||||||
|
help='Number of courses for which to get opt-in information in a single query.')
|
||||||
|
|
||||||
# Fields output in the CSV
|
# Fields output in the CSV
|
||||||
OUTPUT_FIELD_NAMES = [
|
OUTPUT_FIELD_NAMES = [
|
||||||
@@ -77,10 +86,11 @@ class Command(BaseCommand):
|
|||||||
QUERY_INTERVAL = 1000
|
QUERY_INTERVAL = 1000
|
||||||
|
|
||||||
# Default datetime if the user has not set a preference
|
# Default datetime if the user has not set a preference
|
||||||
DEFAULT_DATETIME_STR = datetime.datetime(year=2014, month=12, day=1).isoformat(' ')
|
DEFAULT_DATETIME_STR = datetime.datetime(year=2014, month=12, day=1).isoformat(str(' '))
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
"""Execute the command.
|
"""
|
||||||
|
Execute the command.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
file_path (str): Path to the output file.
|
file_path (str): Path to the output file.
|
||||||
@@ -92,9 +102,12 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
CommandError
|
CommandError
|
||||||
|
|
||||||
"""
|
"""
|
||||||
file_path, org_list = self._parse_args(args)
|
file_path = options['file_path']
|
||||||
|
org_list = options['org_list']
|
||||||
|
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
raise CommandError("File already exists at '{path}'".format(path=file_path))
|
||||||
|
|
||||||
# Retrieve all the courses for the org.
|
# Retrieve all the courses for the org.
|
||||||
# If we were given a specific list of courses to include,
|
# If we were given a specific list of courses to include,
|
||||||
@@ -116,15 +129,15 @@ class Command(BaseCommand):
|
|||||||
# If no courses are found, abort
|
# If no courses are found, abort
|
||||||
if not courses:
|
if not courses:
|
||||||
raise CommandError(
|
raise CommandError(
|
||||||
u"No courses found for orgs: {orgs}".format(
|
"No courses found for orgs: {orgs}".format(
|
||||||
orgs=", ".join(org_list)
|
orgs=", ".join(org_list)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Let the user know what's about to happen
|
# Let the user know what's about to happen
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
u"Retrieving data for courses: {courses}".format(
|
"Retrieving data for courses: {courses}".format(
|
||||||
courses=", ".join([unicode(course) for course in courses])
|
courses=", ".join([text_type(course) for course in courses])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,44 +150,17 @@ class Command(BaseCommand):
|
|||||||
self._write_email_opt_in_prefs(file_handle, org_list, course_group)
|
self._write_email_opt_in_prefs(file_handle, org_list, course_group)
|
||||||
|
|
||||||
# Remind the user where the output file is
|
# Remind the user where the output file is
|
||||||
LOGGER.info(u"Output file: {file_path}".format(file_path=file_path))
|
LOGGER.info("Output file: {file_path}".format(file_path=file_path))
|
||||||
|
|
||||||
def _parse_args(self, args):
|
|
||||||
"""Check and parse arguments.
|
|
||||||
|
|
||||||
Validates that the right number of args were provided
|
|
||||||
and that the output file doesn't already exist.
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
args (list): List of arguments given at the command line.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (file_path, org_list)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
CommandError
|
|
||||||
|
|
||||||
"""
|
|
||||||
if len(args) < 2:
|
|
||||||
raise CommandError(u"Usage: {args}".format(args=self.args))
|
|
||||||
|
|
||||||
file_path = args[0]
|
|
||||||
org_list = args[1:]
|
|
||||||
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
raise CommandError("File already exists at '{path}'".format(path=file_path))
|
|
||||||
|
|
||||||
return file_path, org_list
|
|
||||||
|
|
||||||
def _get_courses_for_org(self, org_aliases):
|
def _get_courses_for_org(self, org_aliases):
|
||||||
"""Retrieve all course keys for a particular org.
|
"""
|
||||||
|
Retrieve all course keys for a particular org.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
org_aliases (list): List of aliases for the org.
|
org_aliases (list): List of aliases for the org.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of `CourseKey`s
|
List of `CourseKey`s
|
||||||
|
|
||||||
"""
|
"""
|
||||||
all_courses = modulestore().get_courses()
|
all_courses = modulestore().get_courses()
|
||||||
orgs_lowercase = [org.lower() for org in org_aliases]
|
orgs_lowercase = [org.lower() for org in org_aliases]
|
||||||
@@ -186,14 +172,17 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def _log_execution_time(self):
|
def _log_execution_time(self):
|
||||||
"""Context manager for measuring execution time. """
|
"""
|
||||||
|
Context manager for measuring execution time.
|
||||||
|
"""
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
yield
|
yield
|
||||||
execution_time = time.time() - start_time
|
execution_time = time.time() - start_time
|
||||||
LOGGER.info(u"Execution time: {time} seconds".format(time=execution_time))
|
LOGGER.info("Execution time: {time} seconds".format(time=execution_time))
|
||||||
|
|
||||||
def _write_email_opt_in_prefs(self, file_handle, org_aliases, courses):
|
def _write_email_opt_in_prefs(self, file_handle, org_aliases, courses):
|
||||||
"""Write email opt-in preferences to the output file.
|
"""
|
||||||
|
Write email opt-in preferences to the output file.
|
||||||
|
|
||||||
This will generate a CSV with one row for each enrollment.
|
This will generate a CSV with one row for each enrollment.
|
||||||
This means that the user's "opt in" preference will be specified
|
This means that the user's "opt in" preference will be specified
|
||||||
@@ -209,14 +198,13 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
None
|
None
|
||||||
|
|
||||||
"""
|
"""
|
||||||
writer = csv.DictWriter(file_handle, fieldnames=self.OUTPUT_FIELD_NAMES)
|
writer = csv.DictWriter(file_handle, fieldnames=self.OUTPUT_FIELD_NAMES)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
|
|
||||||
cursor = self._db_cursor()
|
cursor = self._db_cursor()
|
||||||
query = (
|
query = (
|
||||||
u"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
user.`id` AS `user_id`,
|
user.`id` AS `user_id`,
|
||||||
user.`username` AS username,
|
user.`username` AS username,
|
||||||
@@ -281,17 +269,17 @@ class Command(BaseCommand):
|
|||||||
row_count += 1
|
row_count += 1
|
||||||
|
|
||||||
# Log the number of rows we processed
|
# Log the number of rows we processed
|
||||||
LOGGER.info(u"Retrieved {num_rows} records.".format(num_rows=row_count))
|
LOGGER.info("Retrieved {num_rows} records.".format(num_rows=row_count))
|
||||||
|
|
||||||
def _iterate_results(self, cursor):
|
def _iterate_results(self, cursor):
|
||||||
"""Iterate through the results of a database query, fetching in chunks.
|
"""
|
||||||
|
Iterate through the results of a database query, fetching in chunks.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
cursor: The database cursor
|
cursor: The database cursor
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
tuple of row values from the query
|
tuple of row values from the query
|
||||||
|
|
||||||
"""
|
"""
|
||||||
while True:
|
while True:
|
||||||
rows = cursor.fetchmany(self.QUERY_INTERVAL)
|
rows = cursor.fetchmany(self.QUERY_INTERVAL)
|
||||||
@@ -301,11 +289,15 @@ class Command(BaseCommand):
|
|||||||
yield row
|
yield row
|
||||||
|
|
||||||
def _sql_list(self, values):
|
def _sql_list(self, values):
|
||||||
"""Serialize a list of values for including in a SQL "IN" statement. """
|
"""
|
||||||
return u",".join([u'"{}"'.format(val) for val in values])
|
Serialize a list of values for including in a SQL "IN" statement.
|
||||||
|
"""
|
||||||
|
return ",".join(['"{}"'.format(val) for val in values])
|
||||||
|
|
||||||
def _db_cursor(self):
|
def _db_cursor(self):
|
||||||
"""Return a database cursor to the read replica if one is available. """
|
"""
|
||||||
|
Return a database cursor to the read replica if one is available.
|
||||||
|
"""
|
||||||
# Use the read replica if one has been configured
|
# Use the read replica if one has been configured
|
||||||
db_alias = (
|
db_alias = (
|
||||||
'read_replica'
|
'read_replica'
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ from nose.plugins.attrib import attr
|
|||||||
|
|
||||||
import ddt
|
import ddt
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.management import call_command
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
|
from six import text_type
|
||||||
|
|
||||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||||
from xmodule.modulestore.tests.factories import CourseFactory
|
from xmodule.modulestore.tests.factories import CourseFactory
|
||||||
@@ -214,7 +216,7 @@ class EmailOptInListTest(ModuleStoreTestCase):
|
|||||||
output = self._run_command(self.TEST_ORG, chunk_size=2)
|
output = self._run_command(self.TEST_ORG, chunk_size=2)
|
||||||
course_ids = [row['course_id'].strip().decode('utf-8') for row in output]
|
course_ids = [row['course_id'].strip().decode('utf-8') for row in output]
|
||||||
for course in self.courses:
|
for course in self.courses:
|
||||||
assert unicode(course.id) in course_ids
|
assert text_type(course.id) in course_ids
|
||||||
|
|
||||||
# Choose numbers before and after the query interval boundary
|
# Choose numbers before and after the query interval boundary
|
||||||
@ddt.data(2, 3, 4, 5, 6, 7, 8, 9)
|
@ddt.data(2, 3, 4, 5, 6, 7, 8, 9)
|
||||||
@@ -261,10 +263,10 @@ class EmailOptInListTest(ModuleStoreTestCase):
|
|||||||
def test_not_enough_args(self, num_args):
|
def test_not_enough_args(self, num_args):
|
||||||
args = ["dummy"] * num_args
|
args = ["dummy"] * num_args
|
||||||
expected_msg_regex = (
|
expected_msg_regex = (
|
||||||
"^Usage: <OUTPUT_FILENAME> <ORG_ALIASES> --courses=COURSE_ID_LIST --email-optin-chunk-size=CHUNK_SIZE$"
|
"^Error: too few arguments$"
|
||||||
)
|
)
|
||||||
with self.assertRaisesRegexp(CommandError, expected_msg_regex):
|
with self.assertRaisesRegexp(CommandError, expected_msg_regex):
|
||||||
email_opt_in_list.Command().handle(*args)
|
call_command('email_opt_in_list', *args)
|
||||||
|
|
||||||
def test_file_already_exists(self):
|
def test_file_already_exists(self):
|
||||||
temp_file = tempfile.NamedTemporaryFile(delete=True)
|
temp_file = tempfile.NamedTemporaryFile(delete=True)
|
||||||
@@ -273,7 +275,7 @@ class EmailOptInListTest(ModuleStoreTestCase):
|
|||||||
temp_file.close()
|
temp_file.close()
|
||||||
|
|
||||||
with self.assertRaisesRegexp(CommandError, "^File already exists"):
|
with self.assertRaisesRegexp(CommandError, "^File already exists"):
|
||||||
email_opt_in_list.Command().handle(temp_file.name, self.TEST_ORG)
|
call_command('email_opt_in_list', temp_file.name, self.TEST_ORG)
|
||||||
|
|
||||||
def test_no_user_profile(self):
|
def test_no_user_profile(self):
|
||||||
"""
|
"""
|
||||||
@@ -376,7 +378,7 @@ class EmailOptInListTest(ModuleStoreTestCase):
|
|||||||
output_path = os.path.join(temp_dir_path, self.OUTPUT_FILE_NAME)
|
output_path = os.path.join(temp_dir_path, self.OUTPUT_FILE_NAME)
|
||||||
org_list = [org] + other_names
|
org_list = [org] + other_names
|
||||||
if only_courses is not None:
|
if only_courses is not None:
|
||||||
only_courses = ",".join(unicode(course_id) for course_id in only_courses)
|
only_courses = ",".join(text_type(course_id) for course_id in only_courses)
|
||||||
|
|
||||||
command = email_opt_in_list.Command()
|
command = email_opt_in_list.Command()
|
||||||
|
|
||||||
@@ -388,7 +390,7 @@ class EmailOptInListTest(ModuleStoreTestCase):
|
|||||||
kwargs = {'courses': only_courses}
|
kwargs = {'courses': only_courses}
|
||||||
if chunk_size:
|
if chunk_size:
|
||||||
kwargs['email_optin_chunk_size'] = chunk_size
|
kwargs['email_optin_chunk_size'] = chunk_size
|
||||||
command.handle(output_path, *org_list, **kwargs)
|
call_command('email_opt_in_list', output_path, *org_list, **kwargs)
|
||||||
|
|
||||||
# Retrieve the output from the file
|
# Retrieve the output from the file
|
||||||
try:
|
try:
|
||||||
@@ -442,8 +444,8 @@ class EmailOptInListTest(ModuleStoreTestCase):
|
|||||||
if hasattr(user, 'profile')
|
if hasattr(user, 'profile')
|
||||||
else ''
|
else ''
|
||||||
),
|
),
|
||||||
"course_id": unicode(course_id).encode('utf-8'),
|
"course_id": text_type(course_id).encode('utf-8'),
|
||||||
"is_opted_in_for_email": unicode(opt_in_pref),
|
"is_opted_in_for_email": text_type(opt_in_pref),
|
||||||
"preference_set_datetime": (
|
"preference_set_datetime": (
|
||||||
self._latest_pref_set_datetime(self.user)
|
self._latest_pref_set_datetime(self.user)
|
||||||
if kwargs.get("expect_pref_datetime", True)
|
if kwargs.get("expect_pref_datetime", True)
|
||||||
|
|||||||
Reference in New Issue
Block a user