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 }}
+
+
+
+{% 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'
+ ]