Skip to content

Commit

Permalink
Merge pull request #38 from edly-io/zub/EDLY-816-bulk-enrollment-cron…
Browse files Browse the repository at this point in the history
…-job

Enable bulk enrollment by uploading CSV through celery task
  • Loading branch information
zubair-arbi authored Jan 15, 2020
2 parents cb822c0 + be204ee commit ca0a3c3
Show file tree
Hide file tree
Showing 11 changed files with 330 additions and 3 deletions.
49 changes: 49 additions & 0 deletions lms/djangoapps/instructor/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,55 @@ def students_update_enrollment(request, course_id):
return JsonResponse(response_payload)


def _bulk_enrollment_csv_validator(file_storage, file_to_validate):
"""
Verifies that the expected columns are present in the CSV used to enroll users to course.
"""
with file_storage.open(file_to_validate) as f:
reader = unicodecsv.reader(UniversalNewlineIterator(f), encoding='utf-8')
try:
fieldnames = next(reader)
except StopIteration:
fieldnames = []

if 'email' not in fieldnames:
msg = _("The file must contain an 'email' column.")
raise FileValidationException(msg)


@transaction.non_atomic_requests
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_POST
@require_level('staff')
@common_exceptions_400
def bulk_enroll_users_to_course(request, course_id):
"""
View method that accepts an uploaded file (using key "uploaded-file")
containing users for course enrollment.
This method spawns a celery task to do the enrollments, generates a CSV
file with results and this file is provided via data downloads.
"""
course_key = CourseKey.from_string(course_id)

try:
__, filename = store_uploaded_file(
request,
'uploaded-file',
['.csv'],
course_and_time_based_filename_generator(course_key, 'bulk enrollments'),
max_file_size=2000000, # limit to 2 MB
validator=_bulk_enrollment_csv_validator
)
# The task will assume the default file storage.
lms.djangoapps.instructor_task.api.submit_bulk_users_enrollments(request, course_key, filename)
except (FileValidationException, PermissionDenied) as err:
return JsonResponse({'error': unicode(err)}, status=400)

return JsonResponse()


@require_POST
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
Expand Down
3 changes: 3 additions & 0 deletions lms/djangoapps/instructor/views/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@
# Cohort management
url(r'add_users_to_cohorts$', api.add_users_to_cohorts, name='add_users_to_cohorts'),

# Bulk enrollment management
url(r'enroll_users_to_course$', api.bulk_enroll_users_to_course, name='bulk_enroll_users_to_course'),

# Certificates
url(r'^generate_example_certificates$', api.generate_example_certificates, name='generate_example_certificates'),
url(r'^enable_certificate_generation$', api.enable_certificate_generation, name='enable_certificate_generation'),
Expand Down
5 changes: 4 additions & 1 deletion lms/djangoapps/instructor/views/instructor_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,10 @@ def _section_membership(course, access):
'modify_access_url': reverse('modify_access', kwargs={'course_id': unicode(course_key)}),
'list_forum_members_url': reverse('list_forum_members', kwargs={'course_id': unicode(course_key)}),
'update_forum_role_membership_url': reverse('update_forum_role_membership', kwargs={'course_id': unicode(course_key)}),
'enrollment_role_choices': enrollment_role_choices
'enrollment_role_choices': enrollment_role_choices,
'upload_bulk_enrollments_csv_url': reverse(
'bulk_enroll_users_to_course', kwargs={'course_id': unicode(course_key)}
),
}
return section_data

Expand Down
15 changes: 15 additions & 0 deletions lms/djangoapps/instructor_task/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from lms.djangoapps.instructor_task.models import InstructorTask
from lms.djangoapps.instructor_task.tasks import (
override_problem_score,
bulk_users_enrollments,
calculate_grades_csv,
calculate_may_enroll_csv,
calculate_problem_grade_report,
Expand Down Expand Up @@ -459,6 +460,20 @@ def submit_cohort_students(request, course_key, file_name):
return submit_task(request, task_type, task_class, course_key, task_input, task_key)


def submit_bulk_users_enrollments(request, course_key, file_name):
"""
Request to have users enrolled in bulk.
Raises "AlreadyRunningError" if users are currently being enrolled.
"""
task_type = 'bulk_users_enrollments'
task_class = bulk_users_enrollments
task_input = {'file_name': file_name}
task_key = ''

return submit_task(request, task_type, task_class, course_key, task_input, task_key)


def submit_export_ora2_data(request, course_key):
"""
AlreadyRunningError is raised if an ora2 report is already being generated.
Expand Down
11 changes: 11 additions & 0 deletions lms/djangoapps/instructor_task/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
)
from lms.djangoapps.instructor_task.tasks_helper.grades import CourseGradeReport, ProblemGradeReport, ProblemResponses
from lms.djangoapps.instructor_task.tasks_helper.misc import (
bulk_enroll_students_and_upload,
cohort_students_and_upload,
upload_course_survey_report,
upload_ora2_data,
Expand Down Expand Up @@ -304,6 +305,16 @@ def cohort_students(entry_id, xmodule_instance_args):
return run_main_task(entry_id, task_fn, action_name)


@task(base=BaseInstructorTask)
def bulk_users_enrollments(entry_id, xmodule_instance_args):
"""
Enroll users in bulk, and upload the results.
"""
action_name = ugettext_noop('users enrolled in bulk')
task_fn = partial(bulk_enroll_students_and_upload, xmodule_instance_args)
return run_main_task(entry_id, task_fn, action_name)


@task(base=BaseInstructorTask)
def export_ora2_data(entry_id, xmodule_instance_args):
"""
Expand Down
76 changes: 74 additions & 2 deletions lms/djangoapps/instructor_task/tasks_helper/enrollments.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,23 @@
from time import time

from django.conf import settings
from django.contrib.sites.models import Site
from django.utils.translation import ugettext as _
from pytz import UTC

from courseware.courses import get_course_by_id
from edxmako.shortcuts import render_to_string
from instructor_analytics.basic import enrolled_students_features, list_may_enroll
from instructor_analytics.csvs import format_dictlist
from lms.djangoapps.instructor.paidcourse_enrollment_report import PaidCourseEnrollmentReportProvider
from lms.djangoapps.instructor.enrollment import (
enroll_email,
get_email_params,
get_user_email_language,
)
from lms.djangoapps.instructor_task.models import ReportStore
from lms.djangoapps.instructor.paidcourse_enrollment_report import PaidCourseEnrollmentReportProvider
from lms.djangoapps.instructor.views.tools import get_student_from_identifier
from openedx.core.lib.celery.task_utils import emulate_http_request
from shoppingcart.models import (
CouponRedemption,
CourseRegCodeItem,
Expand All @@ -25,7 +33,7 @@
PaidCourseRegistration,
RegistrationCodeRedemption
)
from student.models import CourseAccessRole, CourseEnrollment
from student.models import CourseAccessRole, CourseEnrollment, User
from util.file import course_filename_prefix_generator

from .runner import TaskProgress
Expand Down Expand Up @@ -391,3 +399,67 @@ def _upload_exec_summary_to_store(data_dict, report_name, course_id, generated_a
output_buffer,
)
tracker_emit(report_name)


def enroll_user_to_course(request_info, course_id, username_or_email):
"""
Look up the given user, and if successful, enroll them to the specified course.
Arguments:
request_info (dict): Dict containing task request information
course_id (str): The ID string of the course
username_or_email: user's username or email string
Returns:
User object (or None if user in not registered,
and whether the user is already enrolled or not
"""
# First try to get a user object from the identifier (email)
user = None
user_already_enrolled = False
language = None
email_students = True
auto_enroll = True
thread_site = Site.objects.get(domain=request_info['host'])
thread_author = User.objects.get(username=request_info['username'])

try:
user = get_student_from_identifier(username_or_email)
except User.DoesNotExist:
email = username_or_email
else:
email = user.email
language = get_user_email_language(user)

if user:
course_enrollment = CourseEnrollment.get_enrollment(user=user, course_key=course_id)
if course_enrollment:
user_already_enrolled = True
# Set the enrollment to active if its not already active
if not course_enrollment.is_active:
course_enrollment.update_enrollment(is_active=True)

if not user or not user_already_enrolled:
course = get_course_by_id(course_id, depth=0)
try:
with emulate_http_request(site=thread_site, user=thread_author):
email_params = get_email_params(course, auto_enroll)
__ = enroll_email(
course_id, email, auto_enroll, email_students, email_params, language=language
)
if user:
TASK_LOG.info(
u'User %s enrolled successfully in course %s via CSV bulk enrollment',
username_or_email,
course_id
)
except:
TASK_LOG.exception(
u'There was an error enrolling user %s in course %s via CSV bulk enrollment',
username_or_email,
course_id
)
return None, None

return user, user_already_enrolled
90 changes: 90 additions & 0 deletions lms/djangoapps/instructor_task/tasks_helper/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from .runner import TaskProgress
from .utils import UPDATE_STATUS_FAILED, UPDATE_STATUS_SUCCEEDED, upload_csv_to_report_store
from .enrollments import enroll_user_to_course

# define different loggers for use within tasks and on client side
TASK_LOG = logging.getLogger('edx.celery.task')
Expand Down Expand Up @@ -236,6 +237,95 @@ def cohort_students_and_upload(_xmodule_instance_args, _entry_id, course_id, tas
return task_progress.update_task_state(extra_meta=current_step)


def bulk_enroll_students_and_upload(_xmodule_instance_args, _entry_id, course_id, task_input, action_name):
"""
Within a given course, enroll students in bulk, then upload the results
using a `ReportStore`.
"""
start_time = time()
start_date = datetime.now(UTC)

# Iterate through rows to get total assignments for task progress
with DefaultStorage().open(task_input['file_name']) as f:
total_assignments = 0
for _line in unicodecsv.DictReader(UniversalNewlineIterator(f)):
total_assignments += 1

task_progress = TaskProgress(action_name, total_assignments, start_time)
current_step = {'step': 'Bulk Enrollment of Students'}
task_progress.update_task_state(extra_meta=current_step)

# enrollments_status is a mapping from course enrollments.
# The metadata will include information about users successfully enrolled
# to the course, users not found, already enrolled user
enrollments_status = {
'Course ID': course_id,
'Learners Enrolled': 0,
'Learners Not Found': set(),
'Learners Already Enrolled': set(),
'Learners Failed To Enroll': set(),
}

with DefaultStorage().open(task_input['file_name']) as f:
for row in unicodecsv.DictReader(UniversalNewlineIterator(f), encoding='utf-8'):
# Try to use the 'email' field to identify the user. If it's not present, use 'username'.
username_or_email = row.get('email') or row.get('username')
task_progress.attempted += 1

try:
# If enroll_user_to_course successfully enrolls a user or user
# is already enrolled, a user object is returned.
# If it is not registered, no user object is returned.
(user, user_already_enrolled) = enroll_user_to_course(
_xmodule_instance_args['request_info'], course_id, username_or_email
)

if user and user_already_enrolled:
enrollments_status['Learners Already Enrolled'].add(username_or_email)
task_progress.skipped += 1
elif user and not user_already_enrolled:
enrollments_status['Learners Enrolled'] += 1
task_progress.succeeded += 1
else:
enrollments_status['Learners Not Found'].add(username_or_email)
task_progress.skipped += 1
except: # pylint: disable=bare-except
TASK_LOG.exception(
u'Exception enrolling user %s in course %s via CSV bulk enrollment',
username_or_email,
course_id
)
enrollments_status['Learners Failed To Enroll'].add(username_or_email)
task_progress.failed += 1

task_progress.update_task_state(extra_meta=current_step)

current_step['step'] = 'Uploading CSV'
task_progress.update_task_state(extra_meta=current_step)

# Filter the output of `bulk_enroll_users_to_course` in order to upload the result.
output_header = [
'Learners Enrolled', 'Learners Not Found', 'Learners Already Enrolled', 'Learners Failed To Enroll',
]

output_rows = [
[
','.join(enrollments_status.get(column_name, '')) if (
column_name == 'Learners Not Found'
or column_name == 'Learners Already Enrolled'
or column_name == 'Learners Failed To Enroll'
)
else enrollments_status[column_name]
for column_name in output_header
]
]

output_rows.insert(0, output_header)
upload_csv_to_report_store(output_rows, 'bulk_enrollment_results', course_id, start_date)

return task_progress.update_task_state(extra_meta=current_step)


def upload_ora2_data(
_xmodule_instance_args, _entry_id, course_id, _task_input, action_name
):
Expand Down
Loading

0 comments on commit ca0a3c3

Please sign in to comment.