diff --git a/openedx/features/enterprise_support/admin/__init__.py b/openedx/features/enterprise_support/admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/features/enterprise_support/admin/forms.py b/openedx/features/enterprise_support/admin/forms.py new file mode 100644 index 0000000000..65f0179647 --- /dev/null +++ b/openedx/features/enterprise_support/admin/forms.py @@ -0,0 +1,21 @@ +""" +Enterprise support admin forms. +""" + +from django import forms +from django.utils.translation import ugettext_lazy as _ +from enterprise.admin.utils import validate_csv + + +class CSVImportForm(forms.Form): + csv_file = forms.FileField( + required=True, + label=_('CSV File'), + help_text=_('CSV file should have 3 columns having names lms_user_id, course_id, opportunity_id') + ) + + def clean_csv_file(self): + csv_file = self.cleaned_data['csv_file'] + csv_reader = validate_csv(csv_file, expected_columns=['lms_user_id', 'course_id', 'opportunity_id']) + + return csv_reader diff --git a/openedx/features/enterprise_support/admin/views.py b/openedx/features/enterprise_support/admin/views.py new file mode 100644 index 0000000000..838d22fbc3 --- /dev/null +++ b/openedx/features/enterprise_support/admin/views.py @@ -0,0 +1,71 @@ +""" +Enterprise support admin views. +""" + +from django.contrib import admin, messages +from django.urls import reverse +from django.utils.translation import ugettext as _ +from django.views.generic.edit import FormView +from enterprise.models import EnterpriseCourseEnrollment + +from openedx.features.enterprise_support.admin.forms import CSVImportForm +from student.models import CourseEnrollment, CourseEnrollmentAttribute + + +class EnrollmentAttributeOverrideView(FormView): + """ + Learner Enrollment Attribute Override View. + """ + template_name = 'enterprise_support/admin/enrollment_attributes_override.html' + form_class = CSVImportForm + + @staticmethod + def _get_admin_context(request): + admin_context = {'opts': EnterpriseCourseEnrollment._meta} + return admin_context + + def get_success_url(self): + return reverse('admin:enterprise_override_attributes') + + def get_context_data(self, **kwargs): + context = super(EnrollmentAttributeOverrideView, self).get_context_data(**kwargs) + context.update(self._get_admin_context(self.request)) + return context + + def form_valid(self, form): + total_records = 0 + error_line_numbers = [] + csv_reader = form.cleaned_data['csv_file'] + for index, record in enumerate(csv_reader): + total_records += 1 + try: + course_enrollment = CourseEnrollment.objects.get( + user_id=record['lms_user_id'], + course_id=record['course_id'], + ) + except CourseEnrollment.DoesNotExist: + error_line_numbers.append(str(index + 1)) + else: + CourseEnrollmentAttribute.objects.update_or_create( + enrollment=course_enrollment, + namespace='salesforce', + name='opportunity_id', + defaults={ + 'value': record['opportunity_id'], + } + ) + + # if for some reason not a single enrollment updated than do not show success message. + if len(error_line_numbers) != total_records: + messages.success(self.request, 'Successfully updated learner enrollment opportunity ids.') + + if error_line_numbers: + messages.error( + self.request, + _( + 'Enrollment attributes were not updated for records at following line numbers ' + 'in csv because no enrollment found for these records: {error_line_numbers}' + ).format(error_line_numbers=', '.join(error_line_numbers)) + ) + + return super(EnrollmentAttributeOverrideView, self).form_valid(form) diff --git a/openedx/features/enterprise_support/templates/enterprise_support/admin/enrollment_attributes_override.html b/openedx/features/enterprise_support/templates/enterprise_support/admin/enrollment_attributes_override.html new file mode 100644 index 0000000000..7d10a8298b --- /dev/null +++ b/openedx/features/enterprise_support/templates/enterprise_support/admin/enrollment_attributes_override.html @@ -0,0 +1,33 @@ +{% extends "admin/base_site.html" %} +{% load i18n static admin_urls %} + +{% block extrastyle %} + + +{% endblock %} + +{% block extrahead %} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+

{% trans "Upload CSV to override enrollment attributes for learners" as tmsg %}{{ tmsg | force_escape }}

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+
+{% endblock %} diff --git a/openedx/features/enterprise_support/tests/test_admin.py b/openedx/features/enterprise_support/tests/test_admin.py new file mode 100644 index 0000000000..d47dd29fc6 --- /dev/null +++ b/openedx/features/enterprise_support/tests/test_admin.py @@ -0,0 +1,145 @@ +""" +Enterprise support admin tests. +""" + +import csv +import os +import tempfile + +from django.contrib.messages import get_messages +from django.test import Client +from django.urls import reverse + +from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory +from openedx.features.enterprise_support.admin.forms import CSVImportForm +from student.models import CourseEnrollment, CourseEnrollmentAttribute +from student.tests.factories import TEST_PASSWORD, AdminFactory, CourseEnrollmentFactory, UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + +class EnrollmentAttributeOverrideViewTest(ModuleStoreTestCase): + """ + Tests for course creator admin. + """ + + def setUp(self): + """ Test case setup """ + super(EnrollmentAttributeOverrideViewTest, self).setUp() + + self.client = Client() + user = AdminFactory() + self.view_url = reverse('admin:enterprise_override_attributes') + self.client.login(username=user.username, password=TEST_PASSWORD) + + self.users = [] + for _ in range(3): + self.users.append(UserFactory()) + + self.course = CourseRunFactory() + self.course_id = self.course.get('key') + self.csv_data = [ + [self.users[0].id, self.course_id, 'OP_4321'], + [self.users[1].id, self.course_id, 'OP_8765'], + [self.users[2].id, self.course_id, 'OP_2109'], + ] + self.csv_data_for_existing_attributes = [ + [self.users[0].id, self.course_id, 'OP_1234'], + [self.users[1].id, self.course_id, 'OP_5678'], + [self.users[2].id, self.course_id, 'OP_9012'], + ] + + for user in self.users: + CourseEnrollmentFactory( + course_id=self.course_id, + user=user + ) + + def create_csv(self, header=None, data=None): + """Create csv""" + header = header or ['lms_user_id', 'course_id', 'opportunity_id'] + data = data or self.csv_data + tmp_csv_path = os.path.join(tempfile.gettempdir(), 'data.csv') + with open(tmp_csv_path, 'w') as csv_file: + csv_writer = csv.writer(csv_file) + csv_writer.writerow(header) + csv_writer.writerows(data) + + return tmp_csv_path + + def verify_enrollment_attributes(self, data=None): + """ + Verify that data from csv is imported correctly and tables have correct data. + """ + data = data or self.csv_data + for user_id, course_id, opportunity_id in data: + enrollment = CourseEnrollment.objects.get(user_id=user_id, course_id=course_id) + enrollment_attribute = CourseEnrollmentAttribute.objects.get( + enrollment=enrollment, + namespace='salesforce', + name='opportunity_id' + ) + assert enrollment_attribute.value == opportunity_id + + def test_get(self): + """ + Tests that HTTP GET is working as expected. + """ + response = self.client.get(self.view_url) + assert response.status_code == 200 + assert isinstance(response.context['form'], CSVImportForm) + + def test_post(self): + """ + Tests that HTTP POST is working as expected when creating new attributes and updating. + """ + csv_path = self.create_csv() + post_data = {'csv_file': open(csv_path)} + response = self.client.post(self.view_url, data=post_data) + assert response.status_code == 302 + self.verify_enrollment_attributes() + + # override existing + csv_path = self.create_csv(data=self.csv_data_for_existing_attributes) + post_data = {'csv_file': open(csv_path)} + response = self.client.post(self.view_url, data=post_data) + assert response.status_code == 302 + self.verify_enrollment_attributes(data=self.csv_data_for_existing_attributes) + + def test_post_with_no_csv(self): + """ + Tests that HTTP POST without out csv file is working as expected. + """ + response = self.client.post(self.view_url) + assert response.context['form'].errors == {'csv_file': ['This field is required.']} + + def test_post_with_incorrect_csv_header(self): + """ + Tests that HTTP POST with incorrect csv header is working as expected. + """ + csv_path = self.create_csv(header=['a', 'b']) + post_data = {'csv_file': open(csv_path)} + response = self.client.post(self.view_url, data=post_data) + assert response.context['form'].errors == { + 'csv_file': [ + 'Expected a CSV file with [lms_user_id, course_id, opportunity_id] ' + 'columns, but found [a, b] columns instead.' + ] + } + + def test_post_with_no_enrollment_error(self): + """ + Tests that HTTP POST is working as expected when for some records there is no enrollment. + """ + csv_data = self.csv_data + [[999, self.course_id, 'NOPE'], [1000, self.course_id, 'NONE']] + csv_path = self.create_csv(data=csv_data) + post_data = {'csv_file': open(csv_path)} + response = self.client.post(self.view_url, data=post_data) + assert response.status_code == 302 + messages = [] + for msg in get_messages(response.wsgi_request): + messages.append(str(msg)) + assert messages == [ + 'Successfully updated learner enrollment opportunity ids.', + 'Enrollment attributes were not updated for records at following line numbers ' + 'in csv because no enrollment found for these records: 4, 5' + ]