diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 5977062a1d..0e84a7f915 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -42,7 +42,8 @@ from student.models import ( UserProfile, UserTestGroup, BulkUnenrollConfiguration, - AccountRecoveryConfiguration + AccountRecoveryConfiguration, + BulkChangeEnrollmentConfiguration ) from student.roles import REGISTERED_ACCESS_ROLES from xmodule.modulestore.django import modulestore @@ -541,6 +542,7 @@ admin.site.register(AccountRecoveryConfiguration, ConfigurationModelAdmin) admin.site.register(DashboardConfiguration, ConfigurationModelAdmin) admin.site.register(RegistrationCookieConfiguration, ConfigurationModelAdmin) admin.site.register(BulkUnenrollConfiguration, ConfigurationModelAdmin) +admin.site.register(BulkChangeEnrollmentConfiguration, ConfigurationModelAdmin) # We must first un-register the User model since it may also be registered by the auth app. diff --git a/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py b/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py index c233784f06..b835cac477 100644 --- a/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py +++ b/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py @@ -4,10 +4,11 @@ csv file. """ -import csv import logging from os import path +import unicodecsv +from django.core.exceptions import ObjectDoesNotExist from django.core.management.base import BaseCommand, CommandError from django.db import transaction from opaque_keys import InvalidKeyError @@ -15,6 +16,8 @@ from opaque_keys.edx.keys import CourseKey from student.models import CourseEnrollment, CourseEnrollmentAttribute, User +from student.models import BulkChangeEnrollmentConfiguration + logger = logging.getLogger('student.management.commands.bulk_change_enrollment_csv') @@ -42,80 +45,126 @@ class Command(BaseCommand): """ Add argument to the command parser. """ parser.add_argument( '--csv_file_path', - required=True, + required=False, help='Csv file path' ) + parser.add_argument( + '--file_from_database', + action='store_true', + help='Use file from the BulkChangeEnrollmentConfiguration model instead of the command line.', + ) + + def get_file_from_database(self): + """ Returns an options dictionary from the current SSPVerificationRetryConfig model. """ + + enrollment_config = BulkChangeEnrollmentConfiguration.current() + if not enrollment_config.enabled: + raise CommandError('BulkChangeEnrollmentConfiguration is disabled or empty, ' + 'but --file_from_database from was requested.') + + return enrollment_config.csv_file def handle(self, *args, **options): """ Main handler for the command.""" - file_path = options['csv_file_path'] + file_path = options.get('csv_file_path', None) + file_from_database = options['file_from_database'] - if not path.isfile(file_path): - raise CommandError("File not found.") + if file_from_database: + csv_file = self.get_file_from_database() + self.change_enrollments(csv_file) - with open(file_path) as csv_file: - course_key = None - user = None - file_reader = csv.DictReader(csv_file) - headers = file_reader.fieldnames + elif file_path: + if not path.isfile(file_path): + raise CommandError("File not found.") - if not ('course_id' in headers and 'mode' in headers and 'user' in headers): - raise CommandError('Invalid input CSV file.') + with open(file_path, 'rb') as csv_file: + self.change_enrollments(csv_file) - for row in file_reader: + else: + CommandError('No file is provided. File is required') + + def change_enrollments(self, csv_file): + """ change the enrollments of the learners. """ + course_key = None + user = None + file_reader = unicodecsv.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 list(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 ObjectDoesNotExist: + logger.warning('Invalid or non-existent user [{}]'.format(row['user'])) + + if course_key and user: try: - course_key = CourseKey.from_string(row['course_id']) - except InvalidKeyError: - logger.warning('Invalid or non-existent course id [{}]'.format(row['course_id'])) + course_enrollment = self.get_course_enrollment(course_key, user) - 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: + mode = row['mode'] + self.update_enrollment_mode(course_key, user, mode, course_enrollment) - 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() + else: + # if student enrollment do not exists directly enroll in new mode. + CourseEnrollment.enroll(user=user, course_key=course_key, mode=row['mode']) - 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: # pylint: disable=broad-except + 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)) - 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)) + def get_course_enrollment(self, course_key, user): + """ + If student is not enrolled in course enroll the student in free mode + """ + + 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 + + return course_enrollment + + def update_enrollment_mode(self, course_key, user, mode, course_enrollment): + """ + update the enrollment mode based on the learner existing state. + """ + # if student already had a enrollment and its mode is same as the provided one + if course_enrollment.mode == 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=mode, + is_active=True, + skip_refund=True + ) + course_enrollment.save() + + if mode == 'credit': + enrollment_attrs = [{'namespace': 'credit', + 'name': 'provider_id', + 'value': course_key.org + }] + CourseEnrollmentAttribute.add_enrollment_attr(enrollment=course_enrollment, + data_list=enrollment_attrs) 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 index 2777bc221e..aa9c38fa51 100644 --- a/common/djangoapps/student/management/tests/test_bulk_change_enrollment_csv.py +++ b/common/djangoapps/student/management/tests/test_bulk_change_enrollment_csv.py @@ -5,7 +5,9 @@ from tempfile import NamedTemporaryFile import six from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile from django.core.management import call_command +from django.core.management.base import CommandError from testfixtures import LogCapture from course_modes.models import CourseMode @@ -15,6 +17,8 @@ from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +from student.models import BulkChangeEnrollmentConfiguration + LOGGER_NAME = 'student.management.commands.bulk_change_enrollment_csv' @@ -120,3 +124,26 @@ class BulkChangeEnrollmentCSVTests(SharedModuleStoreTestCase): 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) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_bulk_enrollment_from_config_model(self): + """ Test all users are enrolled using the config model.""" + lines = "course_id,user,mode\n" + for enrollment in self.enrollments: + lines += str(enrollment.course.id) + "," + str(enrollment.user.username) + ",verified\n" + + csv_file = SimpleUploadedFile(name='test.csv', content=lines.encode('utf-8'), content_type='text/csv') + BulkChangeEnrollmentConfiguration.objects.create(enabled=True, csv_file=csv_file) + call_command("bulk_change_enrollment_csv", "--file_from_database") + + 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) + + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_command_error_for_config_model(self): + """ Test command error raised if file_from_database is required and the config model is not enabled""" + + with self.assertRaises(CommandError): + call_command("bulk_change_enrollment_csv", "--file_from_database") diff --git a/common/djangoapps/student/migrations/0035_bulkchangeenrollmentconfiguration.py b/common/djangoapps/student/migrations/0035_bulkchangeenrollmentconfiguration.py new file mode 100644 index 0000000000..d7f2fac075 --- /dev/null +++ b/common/djangoapps/student/migrations/0035_bulkchangeenrollmentconfiguration.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2.14 on 2020-07-16 10:33 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('student', '0034_courseenrollmentcelebration'), + ] + + operations = [ + migrations.CreateModel( + name='BulkChangeEnrollmentConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('csv_file', models.FileField(help_text='It expect that the data will be provided in a csv file format with first row being the header and columns will be as follows: course_id, username, mode', upload_to='', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['csv'])])), + ('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')), + ], + options={ + 'abstract': False, + 'ordering': ('-change_date',), + }, + ), + ] diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 01f72682ce..9c65b492d8 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -2909,6 +2909,18 @@ class BulkUnenrollConfiguration(ConfigurationModel): ) +class BulkChangeEnrollmentConfiguration(ConfigurationModel): + """ + config model for the bulk_change_enrollment_csv command + """ + csv_file = models.FileField( + validators=[FileExtensionValidator(allowed_extensions=[u'csv'])], + help_text=_(u"It expect that the data will be provided in a csv file format with \ + first row being the header and columns will be as follows: \ + course_id, username, mode") + ) + + @python_2_unicode_compatible class UserAttribute(TimeStampedModel): """