diff --git a/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py b/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py new file mode 100644 index 0000000000000000000000000000000000000000..f3ee234020d84d5fe73d111a68e4bf55275d820c --- /dev/null +++ b/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py @@ -0,0 +1,119 @@ +""" +Management command to change many user enrollments in many courses using +csv file. +""" +import csv +import logging +from os import path + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey + +from student.models import CourseEnrollment, CourseEnrollmentAttribute, User + +logger = logging.getLogger('student.management.commands.bulk_change_enrollment_csv') + + +class Command(BaseCommand): + """ + Management command to change many user enrollments in many + courses using the csv file + """ + + help = """ + Change the enrollment status of all the users specified in + the csv file in the specified course to specified course + mode. + Could be used to update effected users by order + placement issues. If large number of students are effected + in different courses. + Similar to bulk_change_enrollment but uses the csv file + input format and can enroll students in multiple courses. + + Example: + $ ... bulk_change_enrollment_csv csv_file_path + """ + + def add_arguments(self, parser): + """ Add argument to the command parser. """ + parser.add_argument( + '--csv_file_path', + required=True, + help='Csv file path' + ) + + def handle(self, *args, **options): + """ Main handler for the command.""" + file_path = options['csv_file_path'] + + if not path.isfile(file_path): + raise CommandError("File not found.") + + with open(file_path) as csv_file: + course_key = None + user = None + file_reader = csv.DictReader(csv_file) + headers = file_reader.fieldnames + + if not ('course_id' in headers and 'mode' in headers and 'user' in headers): + raise CommandError('Invalid input CSV file.') + + for row in file_reader: + try: + course_key = CourseKey.from_string(row['course_id']) + except InvalidKeyError: + logger.warning('Invalid or non-existent course id [{}]'.format(row['course_id'])) + + try: + user = User.objects.get(username=row['user']) + except: + logger.warning('Invalid or non-existent user [{}]'.format(row['user'])) + if course_key and user: + try: + course_enrollment = CourseEnrollment.get_enrollment(user, course_key) + # If student is not enrolled in course enroll the student in free mode + if not course_enrollment: + # try to create a enroll user in default course enrollment mode in case of + # professional it will break because of no default course mode. + try: + course_enrollment = CourseEnrollment.get_or_create_enrollment(user=user, + course_key=course_key) + except Exception: # pylint: disable=broad-except + # In case if no free mode is available. + course_enrollment = None + + if course_enrollment: + # if student already had a enrollment and its mode is same as the provided one + if course_enrollment.mode == row['mode']: + logger.info("Student [%s] is already enrolled in Course [%s] in mode [%s].", user.username, + course_key, course_enrollment.mode) + # set the enrollment to active if its not already active. + if not course_enrollment.is_active: + course_enrollment.update_enrollment(is_active=True) + else: + # if student enrollment exists update it to new mode. + with transaction.atomic(): + course_enrollment.update_enrollment( + mode=row['mode'], + is_active=True, + skip_refund=True + ) + course_enrollment.save() + + if row['mode'] == 'credit': + enrollment_attrs = [{ + 'namespace': 'credit', + 'name': 'provider_id', + 'value': course_key.org, + }] + CourseEnrollmentAttribute.add_enrollment_attr(enrollment=course_enrollment, + data_list=enrollment_attrs) + else: + # if student enrollment do not exists directly enroll in new mode. + CourseEnrollment.enroll(user=user, course_key=course_key, mode=row['mode']) + + except Exception as e: + logger.info("Unable to update student [%s] course [%s] enrollment to mode [%s] " + "because of Exception [%s]", row['user'], row['course_id'], row['mode'], repr(e)) diff --git a/common/djangoapps/student/management/tests/test_bulk_change_enrollment_csv.py b/common/djangoapps/student/management/tests/test_bulk_change_enrollment_csv.py new file mode 100644 index 0000000000000000000000000000000000000000..4f6ceb2f612261ba05bbcdd3da5f8bdd64de643e --- /dev/null +++ b/common/djangoapps/student/management/tests/test_bulk_change_enrollment_csv.py @@ -0,0 +1,117 @@ +from tempfile import NamedTemporaryFile +import unittest + +from django.conf import settings +from django.core.management import call_command +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from testfixtures import LogCapture + +from course_modes.tests.factories import CourseModeFactory +from course_modes.models import CourseMode +from student.tests.factories import UserFactory +from student.models import CourseEnrollment + + +LOGGER_NAME = 'student.management.commands.bulk_change_enrollment_csv' + + +class BulkChangeEnrollmentCSVTests(SharedModuleStoreTestCase): + """ + Tests bulk_change_enrollmetn_csv command + """ + def setUp(self): + super(BulkChangeEnrollmentCSVTests, self).setUp() + self.courses = [] + + self.user_info = [ + ('amy', 'amy@pond.com', 'password'), + ('rory', 'rory@theroman.com', 'password'), + ('river', 'river@song.com', 'password') + ] + + self.enrollments = [] + self.users = [] + + for username, email, password in self.user_info: + user = UserFactory.create(username=username, email=email, password=password) + self.users.append(user) + course = CourseFactory.create() + CourseModeFactory.create(course_id=course.id, mode_slug=CourseMode.AUDIT) + CourseModeFactory.create(course_id=course.id, mode_slug=CourseMode.VERIFIED) + self.courses.append(course) + self.enrollments.append(CourseEnrollment.enroll(user, course.id, mode=CourseMode.AUDIT)) + + def _write_test_csv(self, csv, lines=None): + """Write a test csv file with the lines provided""" + csv.write("course_id,user,mode,\n") + csv.writelines(lines) + csv.seek(0) + return csv + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_user_not_exist(self): + """Verify that warning is logged for non existing user.""" + with NamedTemporaryFile() as csv: + csv = self._write_test_csv(csv, lines="course-v1:edX+DemoX+Demo_Course,user,audit\n") + + with LogCapture(LOGGER_NAME) as log: + call_command("bulk_change_enrollment_csv", "--csv_file_path={}".format(csv.name)) + log.check( + ( + LOGGER_NAME, + 'WARNING', + 'Invalid or non-existent user [user]' + ) + ) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_invalid_course_key(self): + """Verify in case of invalid course key warning is logged.""" + with NamedTemporaryFile() as csv: + csv = self._write_test_csv(csv, lines="Demo_Course,river,audit\n") + + with LogCapture(LOGGER_NAME) as log: + call_command("bulk_change_enrollment_csv", "--csv_file_path={}".format(csv.name)) + log.check( + ( + LOGGER_NAME, + 'WARNING', + 'Invalid or non-existent course id [Demo_Course]' + ) + ) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_already_enrolled_student(self): + """ Verify in case if a user is already enrolled warning is logged.""" + with NamedTemporaryFile() as csv: + csv = self._write_test_csv(csv, lines=str(self.courses[0].id) + ",amy,audit\n") + + with LogCapture(LOGGER_NAME) as log: + call_command("bulk_change_enrollment_csv", "--csv_file_path={}".format(csv.name)) + log.check( + ( + LOGGER_NAME, + 'INFO', + 'Student [{}] is already enrolled in Course [{}] in mode [{}].'.format( + 'amy', + str(self.courses[0].id), + 'audit', + ) + ) + ) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_bulk_enrollment(self): + """ Test all users are enrolled using the command.""" + lines = (str(enrollment.course.id) + "," + str(enrollment.user.username) + ",verified\n" + for enrollment in self.enrollments) + + with NamedTemporaryFile() as csv: + csv = self._write_test_csv(csv, lines=lines) + call_command("bulk_change_enrollment_csv", "--csv_file_path={}".format(csv.name)) + + for enrollment in self.enrollments: + new_enrollment = CourseEnrollment.get_enrollment(user=enrollment.user, course_key=enrollment.course) + self.assertEqual(new_enrollment.is_active, True) + self.assertEqual(new_enrollment.mode, CourseMode.VERIFIED)