diff --git a/lms/djangoapps/program_enrollments/api/__init__.py b/lms/djangoapps/program_enrollments/api/__init__.py
index 6a6aa4c7f223c1b21fc131d1563d948cd6db6493..104e6122717162c3838df5886dd00c4edbb1a4c5 100644
--- a/lms/djangoapps/program_enrollments/api/__init__.py
+++ b/lms/djangoapps/program_enrollments/api/__init__.py
@@ -1,11 +1,39 @@
 """
-Python API exposed by the proram_enrollments app to other in-process apps.
+Python API exposed by the program_enrollments app to other in-process apps.
 
 The functions are split into separate files for code organization, but they
-are wildcard-imported into here so they can be imported directly from
+are imported into here so they can be imported directly from
 `lms.djangoapps.program_enrollments.api`.
+
+When adding new functions to this API, add them to the appropriate module
+within the /api/ folder, and then "expose" them here by importing them.
+
+We use explicit imports here because (1) it hides internal variables in the
+sub-modules and (2) it provides a nice catalog of functions for someone
+using this API.
 """
 from __future__ import absolute_import
 
-from .linking import *  # pylint: disable=wildcard-import
-from .reading import *  # pylint: disable=wildcard-import
+from .grades import iter_program_course_grades
+from .linking import link_program_enrollment_to_lms_user, link_program_enrollments
+from .reading import (
+    fetch_program_course_enrollments,
+    fetch_program_course_enrollments_by_student,
+    fetch_program_enrollments,
+    fetch_program_enrollments_by_student,
+    get_program_course_enrollment,
+    get_program_enrollment,
+    get_provider_slug,
+    get_saml_provider_for_organization,
+    get_saml_provider_for_program,
+    get_users_by_external_keys
+)
+from .writing import (
+    change_program_course_enrollment_status,
+    change_program_enrollment_status,
+    create_program_course_enrollment,
+    create_program_enrollment,
+    enroll_in_masters_track,
+    write_program_course_enrollments,
+    write_program_enrollments
+)
diff --git a/lms/djangoapps/program_enrollments/api/api.py b/lms/djangoapps/program_enrollments/api/api.py
index 864e9a3c443514496cad65a1ffbb5d7cb83e05a8..84b68edbc346e0d08adc71eddb1be7b876b1f30b 100644
--- a/lms/djangoapps/program_enrollments/api/api.py
+++ b/lms/djangoapps/program_enrollments/api/api.py
@@ -10,9 +10,9 @@ away in https://openedx.atlassian.net/browse/ENT-2294
 """
 from __future__ import absolute_import, unicode_literals
 
+from lms.djangoapps.bulk_email.api import get_emails_enabled as get_emails_enabled_util
 from lms.djangoapps.course_api.api import get_course_run_url as get_course_run_url_util
 from lms.djangoapps.course_api.api import get_due_dates as get_due_dates_util
-from lms.djangoapps.bulk_email.api import get_emails_enabled as get_emails_enabled_util
 
 
 def get_due_dates(request, course_key, user):
diff --git a/lms/djangoapps/program_enrollments/api/grades.py b/lms/djangoapps/program_enrollments/api/grades.py
new file mode 100644
index 0000000000000000000000000000000000000000..6af6f8899176d5c9659a0fd8bde64b1835c275a8
--- /dev/null
+++ b/lms/djangoapps/program_enrollments/api/grades.py
@@ -0,0 +1,135 @@
+"""
+Python API functions related to reading program-course grades.
+
+Outside of this subpackage, import these functions
+from `lms.djangoapps.program_enrollments.api`.
+"""
+from __future__ import absolute_import, unicode_literals
+
+import logging
+
+from six import text_type
+
+from lms.djangoapps.grades.api import CourseGradeFactory, clear_prefetched_course_grades, prefetch_course_grades
+from util.query import read_replica_or_default
+
+from .reading import fetch_program_course_enrollments
+
+logger = logging.getLogger(__name__)
+
+
+def iter_program_course_grades(program_uuid, course_key, paginate_queryset_fn=None):
+    """
+    Load grades (or grading errors) for a given program-course.
+
+    Arguments:
+        program_uuid (str)
+        course_key (CourseKey)
+        paginate_queryset_fn (QuerySet -> QuerySet):
+            Optional function to paginate the results,
+            generally passed in from `self.request.paginate_queryset`
+            on a paginated DRF `APIView`.
+            If `None`, all results will be loaded and returned.
+
+    Returns: generator[BaseProgramCourseGrade]
+    """
+    enrollments_qs = fetch_program_course_enrollments(
+        program_uuid=program_uuid,
+        course_key=course_key,
+        realized_only=True,
+    ).select_related(
+        'program_enrollment',
+        'program_enrollment__user',
+    ).using(read_replica_or_default())
+    enrollments = (
+        paginate_queryset_fn(enrollments_qs) if paginate_queryset_fn
+        else enrollments_qs
+    )
+    if not enrollments:
+        return []
+    return _generate_grades(course_key, list(enrollments))
+
+
+def _generate_grades(course_key, enrollments):
+    """
+    Load enrolled user grades for a program-course,
+    using bulk fetching for efficiency.
+
+    Arguments:
+        course_key (CourseKey)
+        enrollments (list[ProgramCourseEnrollment])
+
+    Yields: BaseProgramCourseGrade
+    """
+    users = [enrollment.program_enrollment.user for enrollment in enrollments]
+    prefetch_course_grades(course_key, users)
+    try:
+        grades_iter = CourseGradeFactory().iter(users, course_key=course_key)
+        for enrollment, grade_tuple in zip(enrollments, grades_iter):
+            user, course_grade, exception = grade_tuple
+            if course_grade:
+                yield ProgramCourseGradeOk(enrollment, course_grade)
+            else:
+                error_template = 'Failed to load course grade for user ID {} in {}: {}'
+                error_string = error_template.format(
+                    user.id,
+                    course_key,
+                    text_type(exception) if exception else 'Unknown error'
+                )
+                logger.error(error_string)
+                yield ProgramCourseGradeError(enrollment, exception)
+    finally:
+        clear_prefetched_course_grades(course_key)
+
+
+class BaseProgramCourseGrade(object):
+    """
+    Base for either a courserun grade or grade-loading failure.
+
+    Can be passed to ProgramCourseGradeResultSerializer.
+    """
+    is_error = None  # Override in subclass
+
+    def __init__(self, program_course_enrollment):
+        """
+        Given a ProgramCourseEnrollment,
+        create a BaseProgramCourseGrade instance.
+        """
+        self.program_course_enrollment = program_course_enrollment
+
+
+class ProgramCourseGradeOk(BaseProgramCourseGrade):
+    """
+    Represents a courserun grade for a user enrolled through a program.
+    """
+    is_error = False
+
+    def __init__(self, program_course_enrollment, course_grade):
+        """
+        Given a ProgramCourseEnrollment and course grade object,
+        create a ProgramCourseGradeOk.
+        """
+        super(ProgramCourseGradeOk, self).__init__(
+            program_course_enrollment
+        )
+        self.passed = course_grade.passed
+        self.percent = course_grade.percent
+        self.letter_grade = course_grade.letter_grade
+
+
+class ProgramCourseGradeError(BaseProgramCourseGrade):
+    """
+    Represents a failure to load a courserun grade for a user enrolled through
+    a program.
+    """
+    is_error = True
+
+    def __init__(self, program_course_enrollment, exception=None):
+        """
+        Given a ProgramCourseEnrollment and an Exception,
+        create a ProgramCourseGradeError.
+        """
+        super(ProgramCourseGradeError, self).__init__(
+            program_course_enrollment
+        )
+        self.error = text_type(exception) if exception else "Unknown error"
diff --git a/lms/djangoapps/program_enrollments/api/linking.py b/lms/djangoapps/program_enrollments/api/linking.py
index adf122b1b90b65f0e1801253bb9c6c4fb87eb233..9538c0833e2136989bead6badbdeba494389e796 100644
--- a/lms/djangoapps/program_enrollments/api/linking.py
+++ b/lms/djangoapps/program_enrollments/api/linking.py
@@ -8,7 +8,6 @@ from `lms.djangoapps.program_enrollments.api`.
 from __future__ import absolute_import, unicode_literals
 
 import logging
-from uuid import UUID
 
 from django.contrib.auth import get_user_model
 from django.db import IntegrityError, transaction
@@ -16,6 +15,7 @@ from django.db import IntegrityError, transaction
 from student.models import CourseEnrollmentException
 
 from .reading import fetch_program_enrollments
+from .writing import enroll_in_masters_track
 
 logger = logging.getLogger(__name__)
 User = get_user_model()
@@ -26,9 +26,6 @@ NO_PROGRAM_ENROLLMENT_TEMPLATE = (
     'key={external_student_key}'
 )
 NO_LMS_USER_TEMPLATE = 'No user found with username {}'
-COURSE_ENROLLMENT_ERR_TEMPLATE = (
-    'Failed to enroll user {user} with waiting program course enrollment for course {course}'
-)
 EXISTING_USER_TEMPLATE = (
     'Program enrollment with external_student_key={external_student_key} is already linked to '
     '{account_relation} account username={username}'
@@ -36,7 +33,7 @@ EXISTING_USER_TEMPLATE = (
 
 
 @transaction.atomic
-def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernames):
+def link_program_enrollments(program_uuid, external_keys_to_usernames):
     """
     Utility function to link ProgramEnrollments to LMS Users
 
@@ -44,10 +41,9 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
         -program_uuid: the program for which we are linking program enrollments
         -external_keys_to_usernames: dict mapping `external_user_keys` to LMS usernames.
 
-    Returns:
-        {
-            (external_key, username): Error message if there was an error
-        }
+    Returns: dict[str: str]
+        Map from external keys to errors, for the external keys of users whose
+        linking produced errors.
 
     Raises: ValueError if None is included in external_keys_to_usernames
 
@@ -60,7 +56,7 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
         - No enrollment is found for the given program and external_user_key
         - The enrollment already has a user
     An error message will be logged, and added to a dictionary of error messages keyed by
-    (external_key, username). The input will be skipped. All other inputs will be processed and
+    external_key. The input will be skipped. All other inputs will be processed and
     enrollments updated, and then the function will return the dictionary of error messages.
 
     If there is an error while enrolling a user in a waiting program course enrollment, the
@@ -70,34 +66,29 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
     user but still have waiting course enrollments. All other inputs will be processed
     normally.
     """
-    _validate_inputs(program_uuid, external_keys_to_usernames)
     errors = {}
     program_enrollments = _get_program_enrollments_by_ext_key(
         program_uuid, external_keys_to_usernames.keys()
     )
-    users = _get_lms_users(external_keys_to_usernames.values())
-    for item in external_keys_to_usernames.items():
-        external_student_key, username = item
-
-        user = users.get(username)
-        error_message = None
+    users_by_username = _get_lms_users(external_keys_to_usernames.values())
+    for external_student_key, username in external_keys_to_usernames.items():
+        program_enrollment = program_enrollments.get(external_student_key)
+        user = users_by_username.get(username)
         if not user:
             error_message = NO_LMS_USER_TEMPLATE.format(username)
-
-        program_enrollment = program_enrollments.get(external_student_key)
-        if not program_enrollment:
+        elif not program_enrollment:
             error_message = NO_PROGRAM_ENROLLMENT_TEMPLATE.format(
                 program_uuid=program_uuid,
                 external_student_key=external_student_key
             )
         elif program_enrollment.user:
-            error_message = user_already_linked_message(program_enrollment, user)
-
+            error_message = _user_already_linked_message(program_enrollment, user)
+        else:
+            error_message = None
         if error_message:
             logger.warning(error_message)
-            errors[item] = error_message
+            errors[external_student_key] = error_message
             continue
-
         try:
             with transaction.atomic():
                 link_program_enrollment_to_lms_user(program_enrollment, user)
@@ -110,28 +101,11 @@ def link_program_enrollments_to_lms_users(program_uuid, external_keys_to_usernam
             if str(e):
                 error_message += ': '
                 error_message += str(e)
-            errors[item] = error_message
+            errors[external_student_key] = error_message
     return errors
 
 
-def link_program_enrollment_to_lms_user(program_enrollment, user):
-    """
-    Attempts to link the given program enrollment to the given user
-    If the enrollment has any program course enrollments, enroll the user in those courses as well
-
-    Raises: CourseEnrollmentException if there is an error enrolling user in a waiting
-            program course enrollment
-            IntegrityError if we try to create invalid records.
-    """
-    try:
-        _link_program_enrollment(program_enrollment, user)
-        _link_course_enrollments(program_enrollment, user)
-    except IntegrityError:
-        logger.exception("Integrity error while linking program enrollments")
-        raise
-
-
-def user_already_linked_message(program_enrollment, user):
+def _user_already_linked_message(program_enrollment, user):
     """
     Creates an error message that the specified program enrollment is already linked to an lms user
     """
@@ -144,12 +118,6 @@ def user_already_linked_message(program_enrollment, user):
     )
 
 
-def _validate_inputs(program_uuid, external_keys_to_usernames):
-    if None in external_keys_to_usernames or None in external_keys_to_usernames.values():
-        raise ValueError('external_user_key or username cannot be None')
-    UUID(str(program_uuid))  # raises ValueError if invalid
-
-
 def _get_program_enrollments_by_ext_key(program_uuid, external_student_keys):
     """
     Does a bulk read of ProgramEnrollments for a given program and list of external student keys
@@ -177,35 +145,37 @@ def _get_lms_users(lms_usernames):
     }
 
 
-def _link_program_enrollment(program_enrollment, user):
+def link_program_enrollment_to_lms_user(program_enrollment, user):
     """
-    Links program enrollment to user.
+    Attempts to link the given program enrollment to the given user
+    If the enrollment has any program course enrollments, enroll the user in those courses as well
 
-    Raises IntegrityError if ProgramEnrollment is invalid
+    Raises: CourseEnrollmentException if there is an error enrolling user in a waiting
+            program course enrollment
+            IntegrityError if we try to create invalid records.
     """
-    logger.info('Linking external student key {} and user {}'.format(
+    link_log_info = 'user id={} with external_user_key={} for program uuid={}'.format(
+        user.id,
         program_enrollment.external_user_key,
-        user.username
-    ))
+        program_enrollment.program_uuid,
+    )
+    logger.info("Linking " + link_log_info)
     program_enrollment.user = user
-    program_enrollment.save()
-
-
-def _link_course_enrollments(program_enrollment, user):
-    """
-    Enrolls user in waiting program course enrollments
-
-    Raises:
-        IntegrityError if a constraint is violated
-        CourseEnrollmentException if there is an issue enrolling the user in a course
-    """
     try:
-        for program_course_enrollment in program_enrollment.program_course_enrollments.all():
-            program_course_enrollment.enroll(user)
+        program_enrollment.save()
+        program_course_enrollments = program_enrollment.program_course_enrollments.all()
+        for pce in program_course_enrollments:
+            pce.course_enrollment = enroll_in_masters_track(
+                user, pce.course_key, pce.status
+            )
+            pce.save()
+    except IntegrityError:
+        logger.error("Integrity error while linking " + link_log_info)
+        raise
     except CourseEnrollmentException as e:
-        error_message = COURSE_ENROLLMENT_ERR_TEMPLATE.format(
-            user=user.username,
-            course=program_course_enrollment.course_key
+        logger.error(
+            "CourseEnrollmentException while linking {}: {}".format(
+                link_log_info, str(e)
+            )
         )
-        logger.exception(error_message)
-        raise type(e)(error_message)
+        raise
diff --git a/lms/djangoapps/program_enrollments/api/reading.py b/lms/djangoapps/program_enrollments/api/reading.py
index 146766feb385c3fc74dc5659f00e7caa1e9627c1..84cc8fcea6ea92e8a7d3af7ba8c9c088dfb36de9 100644
--- a/lms/djangoapps/program_enrollments/api/reading.py
+++ b/lms/djangoapps/program_enrollments/api/reading.py
@@ -6,6 +6,19 @@ from `lms.djangoapps.program_enrollments.api`.
 """
 from __future__ import absolute_import, unicode_literals
 
+from organizations.models import Organization
+from social_django.models import UserSocialAuth
+
+from openedx.core.djangoapps.catalog.utils import get_programs
+from third_party_auth.models import SAMLProviderConfig
+
+from ..exceptions import (
+    BadOrganizationShortNameException,
+    ProgramDoesNotExistException,
+    ProgramHasNoAuthoringOrganizationException,
+    ProviderConfigurationException,
+    ProviderDoesNotExistException
+)
 from ..models import ProgramCourseEnrollment, ProgramEnrollment
 
 _STUDENT_ARG_ERROR_MESSAGE = (
@@ -144,6 +157,7 @@ def fetch_program_course_enrollments(
         users=None,
         external_user_keys=None,
         program_enrollment_statuses=None,
+        program_enrollments=None,
         active_only=False,
         inactive_only=False,
         realized_only=False,
@@ -161,6 +175,7 @@ def fetch_program_course_enrollments(
         * users (iterable[User])
         * external_user_keys (iterable[str])
         * program_enrollment_statuses (iterable[str])
+        * program_enrollments (iterable[ProgramEnrollment])
         * active_only (bool)
         * inactive_only (bool)
         * realized_only (bool)
@@ -185,6 +200,7 @@ def fetch_program_course_enrollments(
         "program_enrollment__user__in": users,
         "program_enrollment__external_user_key__in": external_user_keys,
         "program_enrollment__status__in": program_enrollment_statuses,
+        "program_enrollment__in": program_enrollments,
     }
     if active_only:
         filters["status"] = "active"
@@ -319,3 +335,106 @@ def _remove_none_values(dictionary):
     return {
         key: value for key, value in dictionary.items() if value is not None
     }
+
+
+def get_users_by_external_keys(program_uuid, external_user_keys):
+    """
+    Given a program and a set of external keys,
+    return a dict from external user keys to Users.
+
+    Args:
+        program_uuid (UUID|str):
+            uuid for program these users is/will be enrolled in
+        external_user_keys (sequence[str]):
+            external user keys used by the program creator's IdP.
+
+    Returns: dict[str: User|None]
+        A dict mapping external user keys to Users.
+        If an external user key is not registered, then None is returned instead
+            of a User for that key.
+
+    Raises:
+        ProgramDoesNotExistException
+        ProgramHasNoAuthoringOrganizationException
+        BadOrganizationShortNameException
+        ProviderDoesNotExistsException
+        ProviderConfigurationException
+    """
+    saml_provider = get_saml_provider_for_program(program_uuid)
+    social_auth_uids = {
+        saml_provider.get_social_auth_uid(external_user_key)
+        for external_user_key in external_user_keys
+    }
+    social_auths = UserSocialAuth.objects.filter(uid__in=social_auth_uids)
+    found_users_by_external_keys = {
+        saml_provider.get_remote_id_from_social_auth(social_auth): social_auth.user
+        for social_auth in social_auths
+    }
+    # Default all external keys to None, because external keys
+    # without a User will not appear in `found_users_by_external_keys`.
+    users_by_external_keys = {key: None for key in external_user_keys}
+    users_by_external_keys.update(found_users_by_external_keys)
+    return users_by_external_keys
+
+
+def get_saml_provider_for_program(program_uuid):
+    """
+    Return currently configured SAML provider for the Organization
+    administering the given program.
+
+    Arguments:
+        program_uuid (UUID|str)
+
+    Returns: SAMLProvider
+
+    Raises:
+        ProgramDoesNotExistException
+        ProgramHasNoAuthoringOrganizationException
+        BadOrganizationShortNameException
+    """
+    program = get_programs(uuid=program_uuid)
+    if program is None:
+        raise ProgramDoesNotExistException(program_uuid)
+    authoring_orgs = program.get('authoring_organizations')
+    org_key = authoring_orgs[0].get('key') if authoring_orgs else None
+    if not org_key:
+        raise ProgramHasNoAuthoringOrganizationException(program_uuid)
+    try:
+        organization = Organization.objects.get(short_name=org_key)
+    except Organization.DoesNotExist:
+        raise BadOrganizationShortNameException(org_key)
+    return get_saml_provider_for_organization(organization)
+
+
+def get_saml_provider_for_organization(organization):
+    """
+    Return currently configured SAML provider for the given Organization.
+
+    Arguments:
+        organization: Organization
+
+    Returns: SAMLProvider
+
+    Raises:
+        ProviderDoesNotExistsException
+        ProviderConfigurationException
+    """
+    try:
+        provider_config = organization.samlproviderconfig_set.current_set().get(enabled=True)
+    except SAMLProviderConfig.DoesNotExist:
+        raise ProviderDoesNotExistException(organization)
+    except SAMLProviderConfig.MultipleObjectsReturned:
+        raise ProviderConfigurationException(organization)
+    return provider_config
+
+
+def get_provider_slug(provider_config):
+    """
+    Returns slug identifying a SAML provider.
+
+    Arguments:
+        provider_config: SAMLProvider
+
+    Returns: str
+    """
+    return provider_config.provider_id.strip('saml-')
diff --git a/lms/djangoapps/program_enrollments/api/tests/test_grades.py b/lms/djangoapps/program_enrollments/api/tests/test_grades.py
new file mode 100644
index 0000000000000000000000000000000000000000..cb48754e199c23c4eae05e114c77b66a190f4d2b
--- /dev/null
+++ b/lms/djangoapps/program_enrollments/api/tests/test_grades.py
@@ -0,0 +1,12 @@
+"""
+(Future home of) Tests for program_enrollments grade-reading Python API.
+
+Currently, we do not directly unit test `load_program_course_grades`.
+This is okay for now because it is used in
+`rest_api.v1.views` and is thus tested through `rest_api.v1.tests.test_views`.
+Eventually it would be good to directly test the Python API function and just use
+mocks in the view tests.
+This file serves as a placeholder and reminder to do that the next time there
+is development on the program_enrollments grades API.
+"""
+from __future__ import absolute_import, unicode_literals
diff --git a/lms/djangoapps/program_enrollments/api/tests/test_linking.py b/lms/djangoapps/program_enrollments/api/tests/test_linking.py
index 330def045c594598a13c2622d04e896c5e99d5df..a278b8a9ab4b91f65730ae35cf6c2c78cbea9e93 100644
--- a/lms/djangoapps/program_enrollments/api/tests/test_linking.py
+++ b/lms/djangoapps/program_enrollments/api/tests/test_linking.py
@@ -15,11 +15,10 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import Cou
 from student.tests.factories import UserFactory
 
 from ..linking import (
-    COURSE_ENROLLMENT_ERR_TEMPLATE,
     NO_LMS_USER_TEMPLATE,
     NO_PROGRAM_ENROLLMENT_TEMPLATE,
-    link_program_enrollments_to_lms_users,
-    user_already_linked_message
+    _user_already_linked_message,
+    link_program_enrollments
 )
 
 LOG_PATH = 'lms.djangoapps.program_enrollments.api.linking'
@@ -123,15 +122,6 @@ class TestLinkProgramEnrollmentsMixin(object):
             [course_enrollment.course.id for course_enrollment in course_enrollments]
         )
 
-    def _assert_error_message(self, errors, error_key, logger, log_level, expected_error_msg):
-        logger.check_present((LOG_PATH, log_level, expected_error_msg))
-        self.assertDictEqual(
-            {
-                error_key: expected_error_msg
-            },
-            errors
-        )
-
 
 class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase):
     """ Tests for linking behavior """
@@ -149,7 +139,7 @@ class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase):
         self._create_waiting_course_enrollment(another_program_enrollment, self.fruit_course)
         self._create_waiting_course_enrollment(another_program_enrollment, self.animal_course)
 
-        link_program_enrollments_to_lms_users(self.program, {'0001': self.user_1.username})
+        link_program_enrollments(self.program, {'0001': self.user_1.username})
 
         self._assert_program_enrollment(self.user_1, self.program, '0001')
         self._assert_user_enrolled_in_program_courses(
@@ -175,7 +165,7 @@ class TestLinkProgramEnrollments(TestLinkProgramEnrollmentsMixin, TestCase):
             status='inactive'
         )
 
-        link_program_enrollments_to_lms_users(self.program, {'0001': self.user_1.username})
+        link_program_enrollments(self.program, {'0001': self.user_1.username})
 
         self._assert_program_enrollment(self.user_1, self.program, '0001')
 
@@ -209,7 +199,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
         asserts that user_2 was not linked because the enrollment was not found
         """
         with LogCapture() as logger:
-            errors = link_program_enrollments_to_lms_users(
+            errors = link_program_enrollments(
                 self.program,
                 {
                     '0001': self.user_1.username,
@@ -222,7 +212,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
             )
             logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
 
-        self.assertDictEqual(errors, {('0002', self.user_2.username): expected_error_msg})
+        self.assertDictEqual(errors, {'0002': expected_error_msg})
         self._assert_program_enrollment(self.user_1, self.program, '0001')
         self._assert_no_program_enrollment(self.user_2, self.program)
 
@@ -231,7 +221,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
         enrollment_2 = self._create_waiting_enrollment(self.program, '0002')
 
         with LogCapture() as logger:
-            errors = link_program_enrollments_to_lms_users(
+            errors = link_program_enrollments(
                 self.program,
                 {
                     '0001': self.user_1.username,
@@ -241,7 +231,7 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
             expected_error_msg = NO_LMS_USER_TEMPLATE.format('nonexistant-user')
             logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
 
-        self.assertDictEqual(errors, {('0002', 'nonexistant-user'): expected_error_msg})
+        self.assertDictEqual(errors, {'0002': expected_error_msg})
         self._assert_program_enrollment(self.user_1, self.program, '0001')
         self._assert_no_user(enrollment_2)
 
@@ -256,17 +246,17 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
         self._assert_program_enrollment(self.user_2, self.program, '0002', refresh=False)
 
         with LogCapture() as logger:
-            errors = link_program_enrollments_to_lms_users(
+            errors = link_program_enrollments(
                 self.program,
                 {
                     '0001': self.user_1.username,
                     '0002': self.user_2.username
                 }
             )
-            expected_error_msg = user_already_linked_message(program_enrollment, self.user_2)
+            expected_error_msg = _user_already_linked_message(program_enrollment, self.user_2)
             logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
 
-        self.assertDictEqual(errors, {('0002', self.user_2.username): expected_error_msg})
+        self.assertDictEqual(errors, {'0002': expected_error_msg})
         self._assert_program_enrollment(self.user_1, self.program, '0001')
         self._assert_program_enrollment(self.user_2, self.program, '0002')
 
@@ -283,17 +273,17 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
         self._assert_program_enrollment(user_3, self.program, '0003', refresh=False)
 
         with LogCapture() as logger:
-            errors = link_program_enrollments_to_lms_users(
+            errors = link_program_enrollments(
                 self.program,
                 {
                     '0001': self.user_1.username,
                     '0003': self.user_2.username,
                 }
             )
-            expected_error_msg = user_already_linked_message(enrollment, self.user_2)
+            expected_error_msg = _user_already_linked_message(enrollment, self.user_2)
             logger.check_present((LOG_PATH, 'WARNING', expected_error_msg))
 
-        self.assertDictEqual(errors, {('0003', self.user_2.username): expected_error_msg})
+        self.assertDictEqual(errors, {'0003': expected_error_msg})
         self._assert_program_enrollment(self.user_1, self.program, '0001')
         self._assert_no_program_enrollment(self.user_2, self.program)
         self._assert_program_enrollment(user_3, self.program, '0003')
@@ -313,22 +303,14 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
         self._create_waiting_course_enrollment(program_enrollment_2, self.fruit_course)
         self._create_waiting_course_enrollment(program_enrollment_2, self.animal_course)
 
-        msg = COURSE_ENROLLMENT_ERR_TEMPLATE.format(
-            user=self.user_1.username, course=nonexistant_course
-        )
-        with LogCapture() as logger:
-            errors = link_program_enrollments_to_lms_users(
-                self.program,
-                {
-                    '0001': self.user_1.username,
-                    '0002': self.user_2.username
-                }
-            )
-            logger.check_present((LOG_PATH, 'ERROR', msg))
-
-        self.assertDictEqual(
-            errors, {('0001', self.user_1.username): 'NonExistentCourseError: ' + msg}
+        errors = link_program_enrollments(
+            self.program,
+            {
+                '0001': self.user_1.username,
+                '0002': self.user_2.username
+            }
         )
+        self.assertIn(errors['0001'], 'NonExistentCourseError: ')
         self._assert_no_program_enrollment(self.user_1, self.program)
         self._assert_no_user(program_enrollment_1)
         course_enrollment_1.refresh_from_db()
@@ -348,46 +330,15 @@ class TestLinkProgramEnrollmentsErrors(TestLinkProgramEnrollmentsMixin, TestCase
         program_enrollment_1 = self._create_waiting_enrollment(self.program, '0001')
         self._create_waiting_enrollment(self.program, '0002')
 
-        msg = 'Integrity error while linking program enrollments'
-        with LogCapture() as logger:
-            errors = link_program_enrollments_to_lms_users(
-                self.program,
-                {
-                    '0001': self.user_1.username,
-                    '0002': self.user_2.username,
-                }
-            )
-            logger.check_present((LOG_PATH, 'ERROR', msg))
+        errors = link_program_enrollments(
+            self.program,
+            {
+                '0001': self.user_1.username,
+                '0002': self.user_2.username,
+            }
+        )
 
         self.assertEqual(len(errors), 1)
-        self.assertIn('UNIQUE constraint failed', errors[('0001', self.user_1.username)])
+        self.assertIn('UNIQUE constraint failed', errors['0001'])
         self._assert_no_user(program_enrollment_1)
         self._assert_program_enrollment(self.user_2, self.program, '0002')
-
-    def test_invalid_uuid(self):
-        self._create_waiting_enrollment(self.program, 'learner-0')
-        with self.assertRaisesMessage(ValueError, 'badly formed hexadecimal UUID string'):
-            link_program_enrollments_to_lms_users(
-                'notauuid::thisisntauuid',
-                {
-                    'learner-0': self.user_1.username,
-                }
-            )
-
-    def test_None(self):
-        self._create_waiting_enrollment(self.program, 'learner-0')
-        msg = 'external_user_key or username cannot be None'
-        with self.assertRaisesMessage(ValueError, msg):
-            link_program_enrollments_to_lms_users(
-                self.program,
-                {
-                    None: self.user_1.username,
-                }
-            )
-        with self.assertRaisesMessage(ValueError, msg):
-            link_program_enrollments_to_lms_users(
-                'notauuid::thisisntauuid',
-                {
-                    'learner-0': None,
-                }
-            )
diff --git a/lms/djangoapps/program_enrollments/api/tests/test_reading.py b/lms/djangoapps/program_enrollments/api/tests/test_reading.py
index 0526944ee09922046a39f8e3841849c0c4aa562a..6f4b3ab0e96c6d43f5d82f3078eaa87bda43ec95 100644
--- a/lms/djangoapps/program_enrollments/api/tests/test_reading.py
+++ b/lms/djangoapps/program_enrollments/api/tests/test_reading.py
@@ -1,5 +1,5 @@
 """
-Tests for account linking Python API.
+Tests for program enrollment reading Python API.
 """
 from __future__ import absolute_import, unicode_literals
 
@@ -7,16 +7,30 @@ from uuid import UUID
 
 import ddt
 from django.contrib.auth import get_user_model
+from django.core.cache import cache
 from django.test import TestCase
 from opaque_keys.edx.keys import CourseKey
+from organizations.tests.factories import OrganizationFactory
+from social_django.models import UserSocialAuth
 
 from course_modes.models import CourseMode
 from lms.djangoapps.program_enrollments.constants import ProgramCourseEnrollmentStatuses as PCEStatuses
 from lms.djangoapps.program_enrollments.constants import ProgramEnrollmentStatuses as PEStatuses
+from lms.djangoapps.program_enrollments.exceptions import (
+    OrganizationDoesNotExistException,
+    ProgramDoesNotExistException,
+    ProviderConfigurationException,
+    ProviderDoesNotExistException
+)
 from lms.djangoapps.program_enrollments.models import ProgramEnrollment
 from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
+from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL
+from openedx.core.djangoapps.catalog.tests.factories import OrganizationFactory as CatalogOrganizationFactory
+from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
 from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
+from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
 from student.tests.factories import CourseEnrollmentFactory, UserFactory
+from third_party_auth.tests.factories import SAMLProviderConfigFactory
 
 from ..reading import (
     fetch_program_course_enrollments,
@@ -24,7 +38,8 @@ from ..reading import (
     fetch_program_enrollments,
     fetch_program_enrollments_by_student,
     get_program_course_enrollment,
-    get_program_enrollment
+    get_program_enrollment,
+    get_users_by_external_keys
 )
 
 User = get_user_model()
@@ -425,3 +440,136 @@ class ProgramEnrollmentReadingTests(TestCase):
             )
             del result['usernames']
         return result
+
+
+class GetUsersByExternalKeysTests(CacheIsolationTestCase):
+    """
+    Tests for the get_users_by_external_keys function
+    """
+    ENABLED_CACHES = ['default']
+
+    @classmethod
+    def setUpTestData(cls):
+        super(GetUsersByExternalKeysTests, cls).setUpTestData()
+        cls.program_uuid = UUID('e7a82f8d-d485-486b-b733-a28222af92bf')
+        cls.organization_key = 'ufo'
+        cls.external_user_id = '1234'
+        cls.user_0 = UserFactory(username='user-0')
+        cls.user_1 = UserFactory(username='user-1')
+        cls.user_2 = UserFactory(username='user-2')
+
+    def setUp(self):
+        super(GetUsersByExternalKeysTests, self).setUp()
+        catalog_org = CatalogOrganizationFactory.create(key=self.organization_key)
+        program = ProgramFactory.create(
+            uuid=self.program_uuid,
+            authoring_organizations=[catalog_org]
+        )
+        cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=self.program_uuid), program, None)
+
+    def create_social_auth_entry(self, user, provider, external_id):
+        """
+        helper functio to create a user social auth entry
+        """
+        UserSocialAuth.objects.create(
+            user=user,
+            uid='{0}:{1}'.format(provider.slug, external_id),
+            provider=provider.backend_name,
+        )
+
+    def test_happy_path(self):
+        """
+        Test that get_users_by_external_keys returns the expected
+        mapping of external keys to users.
+        """
+        organization = OrganizationFactory.create(short_name=self.organization_key)
+        provider = SAMLProviderConfigFactory.create(organization=organization)
+        self.create_social_auth_entry(self.user_0, provider, 'ext-user-0')
+        self.create_social_auth_entry(self.user_1, provider, 'ext-user-1')
+        self.create_social_auth_entry(self.user_2, provider, 'ext-user-2')
+        requested_keys = {'ext-user-1', 'ext-user-2', 'ext-user-3'}
+        actual = get_users_by_external_keys(self.program_uuid, requested_keys)
+        # ext-user-0 not requested, ext-user-3 doesn't exist
+        expected = {
+            'ext-user-1': self.user_1,
+            'ext-user-2': self.user_2,
+            'ext-user-3': None,
+        }
+        assert actual == expected
+
+    def test_empty_request(self):
+        """
+        Test that requesting no external keys does not cause an exception.
+        """
+        organization = OrganizationFactory.create(short_name=self.organization_key)
+        SAMLProviderConfigFactory.create(organization=organization)
+        actual = get_users_by_external_keys(self.program_uuid, set())
+        assert actual == {}
+
+    def test_catalog_program_does_not_exist(self):
+        """
+        Test ProgramDoesNotExistException is thrown if the program cache does
+        not include the requested program uuid.
+        """
+        fake_program_uuid = UUID('80cc59e5-003e-4664-a582-48da44bc7e12')
+        with self.assertRaises(ProgramDoesNotExistException):
+            get_users_by_external_keys(fake_program_uuid, [])
+
+    def test_catalog_program_missing_org(self):
+        """
+        Test OrganizationDoesNotExistException is thrown if the cached program does not
+        have an authoring organization.
+        """
+        program = ProgramFactory.create(
+            uuid=self.program_uuid,
+            authoring_organizations=[]
+        )
+        cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=self.program_uuid), program, None)
+        with self.assertRaises(OrganizationDoesNotExistException):
+            get_users_by_external_keys(self.program_uuid, [])
+
+    def test_lms_organization_not_found(self):
+        """
+        Test an OrganizationDoesNotExistException is thrown if the LMS has no organization
+        matching the catalog program's authoring_organization
+        """
+        organization = OrganizationFactory.create(short_name='some_other_org')
+        SAMLProviderConfigFactory.create(organization=organization)
+        with self.assertRaises(OrganizationDoesNotExistException):
+            get_users_by_external_keys(self.program_uuid, [])
+
+    def test_saml_provider_not_found(self):
+        """
+        Test that Prov exception is thrown if no SAML provider exists for this
+        program's organization.
+        """
+        OrganizationFactory.create(short_name=self.organization_key)
+        with self.assertRaises(ProviderDoesNotExistException):
+            get_users_by_external_keys(self.program_uuid, [])
+
+    def test_extra_saml_provider_disabled(self):
+        """
+        If multiple samlprovider records exist with the same organization,
+        but the extra record is disabled, no exception is raised.
+        """
+        organization = OrganizationFactory.create(short_name=self.organization_key)
+        SAMLProviderConfigFactory.create(organization=organization)
+        # create a second active config for the same organization, NOT enabled
+        SAMLProviderConfigFactory.create(
+            organization=organization, slug='foox', enabled=False
+        )
+        get_users_by_external_keys(self.program_uuid, [])
+
+    def test_extra_saml_provider_enabled(self):
+        """
+        If multiple enabled samlprovider records exist with the same organization
+        an exception is raised.
+        """
+        organization = OrganizationFactory.create(short_name=self.organization_key)
+        SAMLProviderConfigFactory.create(organization=organization)
+        # create a second active config for the same organizationm, IS enabled
+        SAMLProviderConfigFactory.create(
+            organization=organization, slug='foox', enabled=True
+        )
+        with self.assertRaises(ProviderConfigurationException):
+            get_users_by_external_keys(self.program_uuid, [])
diff --git a/lms/djangoapps/program_enrollments/api/tests/test_writing.py b/lms/djangoapps/program_enrollments/api/tests/test_writing.py
new file mode 100644
index 0000000000000000000000000000000000000000..f3f2ba2083c4a509b0cc781bf18b5b2532ec18df
--- /dev/null
+++ b/lms/djangoapps/program_enrollments/api/tests/test_writing.py
@@ -0,0 +1,12 @@
+"""
+(Future home of) Tests for program enrollment writing Python API.
+
+Currently, we do not directly unit test the functions in api/writing.py.
+This is okay for now because they are all used in
+`rest_api.v1.views` and is thus tested through `rest_api.v1.tests.test_views`.
+Eventually it would be good to directly test the Python API function and just use
+mocks in the view tests.
+This file serves as a placeholder and reminder to do that the next time there
+is development on the program_enrollments writing API.
+"""
+from __future__ import absolute_import, unicode_literals
diff --git a/lms/djangoapps/program_enrollments/api/writing.py b/lms/djangoapps/program_enrollments/api/writing.py
new file mode 100644
index 0000000000000000000000000000000000000000..f318418e0c82f73e36ecfd20ba5fb36cbf9bb37e
--- /dev/null
+++ b/lms/djangoapps/program_enrollments/api/writing.py
@@ -0,0 +1,426 @@
+"""
+Python API functions related to writing program enrollments.
+
+Outside of this subpackage, import these functions
+from `lms.djangoapps.program_enrollments.api`.
+"""
+from __future__ import absolute_import, unicode_literals
+
+import logging
+
+from course_modes.models import CourseMode
+from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
+from student.models import CourseEnrollment, NonExistentCourseError
+
+from ..constants import ProgramCourseEnrollmentStatuses
+from ..constants import ProgramCourseOperationStatuses as ProgramCourseOpStatuses
+from ..constants import ProgramEnrollmentStatuses
+from ..constants import ProgramOperationStatuses as ProgramOpStatuses
+from ..exceptions import ProviderDoesNotExistException
+from ..models import ProgramCourseEnrollment, ProgramEnrollment
+from .reading import fetch_program_course_enrollments, fetch_program_enrollments, get_users_by_external_keys
+
+logger = logging.getLogger(__name__)
+
+
+def write_program_enrollments(program_uuid, enrollment_requests, create, update):
+    """
+    Bulk create/update a set of program enrollments.
+
+    Arguments:
+        program_uuid (UUID|str)
+        enrollment_requests (list[dict]): dicts in the form:
+            * 'external_user_key': str
+            * 'status': str from ProgramEnrollmentStatuses
+            * 'curriculum_uuid': str, omittable if `create==False`.
+        create (bool): non-existent enrollments will be created iff `create`,
+            otherwise they will be skipped as 'duplicate'.
+        update (bool): existing enrollments will be updated iff `update`,
+            otherwise they will be skipped as 'not-in-program'
+
+    At least one of `create` or `update` must be True.
+
+    Returns: dict[str: str]
+        Mapping of external user keys to strings from ProgramOperationStatuses.
+    """
+    if not (create or update):
+        raise ValueError("At least one of (create, update) must be True")
+    requests_by_key, duplicated_keys = _organize_requests_by_external_key(enrollment_requests)
+    external_keys = set(requests_by_key)
+    try:
+        users_by_key = get_users_by_external_keys(program_uuid, external_keys)
+    except ProviderDoesNotExistException:
+        # Organization has not yet set up their identity provider.
+        # Just act as if none of the external users have been registered.
+        users_by_key = {key: None for key in external_keys}
+
+    # Fetch existing program enrollments.
+    existing_enrollments = fetch_program_enrollments(
+        program_uuid=program_uuid, external_user_keys=external_keys
+    )
+    existing_enrollments_by_key = {key: None for key in external_keys}
+    existing_enrollments_by_key.update({
+        enrollment.external_user_key: enrollment
+        for enrollment in existing_enrollments
+    })
+
+    # For each enrollment request, try to create/update:
+    # * For creates, build up list `to_save`, which we will bulk-create afterwards.
+    # * For updates, do them in place.
+    #     (TODO: Django 2.2 will add bulk-update support, which we could use here)
+    # Update `results` with the new status or an error status for each operation.
+    results = {}
+    to_save = []
+    for external_key, request in requests_by_key.items():
+        status = request['status']
+        if status not in ProgramEnrollmentStatuses.__ALL__:
+            results[external_key] = ProgramOpStatuses.INVALID_STATUS
+            continue
+        user = users_by_key[external_key]
+        existing_enrollment = existing_enrollments_by_key.get(external_key)
+        if existing_enrollment:
+            if not update:
+                results[external_key] = ProgramOpStatuses.CONFLICT
+                continue
+            results[external_key] = change_program_enrollment_status(
+                existing_enrollment, status
+            )
+        else:
+            if not create:
+                results[external_key] = ProgramOpStatuses.NOT_IN_PROGRAM
+                continue
+            new_enrollment = create_program_enrollment(
+                program_uuid=program_uuid,
+                curriculum_uuid=request['curriculum_uuid'],
+                user=user,
+                external_user_key=external_key,
+                status=status,
+                save=False,
+            )
+            to_save.append(new_enrollment)
+            results[external_key] = new_enrollment.status
+
+    # Bulk-create all new program enrollments.
+    # Note: this will NOT invoke `save()` or `pre_save`/`post_save` signals!
+    # See https://docs.djangoproject.com/en/1.11/ref/models/querysets/#bulk-create.
+    if to_save:
+        ProgramEnrollment.objects.bulk_create(to_save)
+
+    results.update({key: ProgramOpStatuses.DUPLICATED for key in duplicated_keys})
+    return results
+
+
+def create_program_enrollment(
+        program_uuid,
+        curriculum_uuid,
+        user,
+        external_user_key,
+        status,
+        save=True,
+):
+    """
+    Create a program enrollment.
+
+    Arguments:
+        program_uuid (UUID|str)
+        curriculum_uuid (str)
+        user (User)
+        external_user_key (str)
+        status (str): from ProgramEnrollmentStatuses
+        save (bool): Whether to save the created ProgamEnrollment.
+            Defaults to True. One may set this to False in order to
+            bulk-create the enrollments.
+
+    Returns: ProgramEnrollment
+    """
+    if not (user or external_user_key):
+        raise ValueError("At least one of (user, external_user_key) must be ")
+    program_enrollment = ProgramEnrollment(
+        program_uuid=program_uuid,
+        curriculum_uuid=curriculum_uuid,
+        user=user,
+        external_user_key=external_user_key,
+        status=status,
+    )
+    if save:
+        program_enrollment.save()
+    return program_enrollment
+
+
+def change_program_enrollment_status(program_enrollment, new_status):
+    """
+    Update a program enrollment with a new status.
+
+    Arguments:
+        program_enrollment (ProgramEnrollment)
+        status (str): from ProgramCourseEnrollmentStatuses
+
+    Returns: str
+        String from ProgramOperationStatuses.
+    """
+    if new_status not in ProgramEnrollmentStatuses.__ALL__:
+        return ProgramOpStatuses.INVALID_STATUS
+    program_enrollment.status = new_status
+    program_enrollment.save()
+    return program_enrollment.status
+
+
+def write_program_course_enrollments(
+        program_uuid,
+        course_key,
+        enrollment_requests,
+        create,
+        update,
+):
+    """
+    Bulk create/update a set of program-course enrollments.
+
+    Arguments:
+        program_uuid (UUID|str)
+        enrollment_requests (list[dict]): dicts in the form:
+            * 'external_user_key': str
+            * 'status': str from ProgramCourseEnrollmentStatuses
+        create (bool): non-existent enrollments will be created iff `create`,
+            otherwise they will be skipped as 'duplicate'.
+        update (bool): existing enrollments will be updated iff `update`,
+            otherwise they will be skipped as 'not-in-program'
+
+    At least one of `create` or `update` must be True.
+
+    Returns: dict[str: str]
+        Mapping of external user keys to strings from ProgramCourseOperationStatuses.
+    """
+    if not (create or update):
+        raise ValueError("At least one of (create, update) must be True")
+    requests_by_key, duplicated_keys = _organize_requests_by_external_key(enrollment_requests)
+    external_keys = set(requests_by_key)
+    program_enrollments = fetch_program_enrollments(
+        program_uuid=program_uuid,
+        external_user_keys=external_keys,
+    ).prefetch_related('program_course_enrollments')
+    program_enrollments_by_key = {
+        enrollment.external_user_key: enrollment for enrollment in program_enrollments
+    }
+
+    # Fetch existing program-course enrollments.
+    existing_course_enrollments = fetch_program_course_enrollments(
+        program_uuid, course_key, program_enrollments=program_enrollments,
+    )
+    existing_course_enrollments_by_key = {key: None for key in external_keys}
+    existing_course_enrollments_by_key.update({
+        enrollment.program_enrollment.external_user_key: enrollment
+        for enrollment in existing_course_enrollments
+    })
+
+    # For each enrollment request, try to create/update.
+    # For creates, build up list `to_save`, which we will bulk-create afterwards.
+    # For updates, do them in place (Django 2.2 will add bulk-update support).
+    # For each operation, update `results` with the new status or an error status.
+    results = {}
+    to_save = []
+    for external_key, request in requests_by_key.items():
+        status = request['status']
+        program_enrollment = program_enrollments_by_key.get(external_key)
+        if not program_enrollment:
+            results[external_key] = ProgramCourseOpStatuses.NOT_IN_PROGRAM
+            continue
+        if status not in ProgramCourseEnrollmentStatuses.__ALL__:
+            results[external_key] = ProgramCourseOpStatuses.INVALID_STATUS
+            continue
+        existing_course_enrollment = existing_course_enrollments_by_key[external_key]
+        if existing_course_enrollment:
+            if not update:
+                results[external_key] = ProgramCourseOpStatuses.CONFLICT
+                continue
+            results[external_key] = change_program_course_enrollment_status(
+                existing_course_enrollment, status
+            )
+        else:
+            if not create:
+                results[external_key] = ProgramCourseOpStatuses.NOT_FOUND
+                continue
+            new_course_enrollment = create_program_course_enrollment(
+                program_enrollment, course_key, status, save=False
+            )
+            to_save.append(new_course_enrollment)
+            results[external_key] = new_course_enrollment.status
+
+    # Bulk-create all new program-course enrollments.
+    # Note: this will NOT invoke `save()` or `pre_save`/`post_save` signals!
+    # See https://docs.djangoproject.com/en/1.11/ref/models/querysets/#bulk-create.
+    if to_save:
+        ProgramCourseEnrollment.objects.bulk_create(to_save)
+
+    results.update({
+        key: ProgramCourseOpStatuses.DUPLICATED for key in duplicated_keys
+    })
+    return results
+
+
+def create_program_course_enrollment(program_enrollment, course_key, status, save=True):
+    """
+    Create a program course enrollment.
+
+    If `program_enrollment` is realized (i.e., has a non-null User),
+    then also create a course enrollment.
+
+    Arguments:
+        program_enrollment (ProgramEnrollment)
+        course_key (CourseKey|str)
+        status (str): from ProgramCourseEnrollmentStatuses
+        save (bool): Whether to save the created ProgamCourseEnrollment.
+            Defaults to True. One may set this to False in order to
+            bulk-create the enrollments.
+            Note that if a CourseEnrollment is created, it will be saved
+            regardless of this value.
+
+    Returns: ProgramCourseEnrollment
+
+    Raises: NonExistentCourseError
+    """
+    _ensure_course_exists(course_key, program_enrollment.external_user_key)
+    course_enrollment = (
+        enroll_in_masters_track(program_enrollment.user, course_key, status)
+        if program_enrollment.user
+        else None
+    )
+    program_course_enrollment = ProgramCourseEnrollment(
+        program_enrollment=program_enrollment,
+        course_key=course_key,
+        course_enrollment=course_enrollment,
+        status=status,
+    )
+    if save:
+        program_course_enrollment.save()
+    return program_course_enrollment
+
+
+def change_program_course_enrollment_status(program_course_enrollment, new_status):
+    """
+    Update a program course enrollment with a new status.
+
+    If `program_course_enrollment` is realized with a CourseEnrollment,
+    then also update that.
+
+    Arguments:
+        program_course_enrollment (ProgramCourseEnrollment)
+        status (str): from ProgramCourseEnrollmentStatuses
+
+    Returns: str
+        String from ProgramOperationCourseStatuses.
+    """
+    if new_status == program_course_enrollment.status:
+        return new_status
+    if new_status == ProgramCourseEnrollmentStatuses.ACTIVE:
+        active = True
+    elif new_status == ProgramCourseEnrollmentStatuses.INACTIVE:
+        active = False
+    else:
+        return ProgramCourseOpStatuses.INVALID_STATUS
+    if program_course_enrollment.course_enrollment:
+        if active:
+            program_course_enrollment.course_enrollment.activate()
+        else:
+            program_course_enrollment.course_enrollment.deactivate()
+    program_course_enrollment.status = new_status
+    program_course_enrollment.save()
+    return program_course_enrollment.status
+
+
+def enroll_in_masters_track(user, course_key, status):
+    """
+    Ensure that the user is enrolled in the Master's track of course.
+    Either creates or updates a course enrollment.
+
+    Arguments:
+        user (User)
+        course_key (CourseKey|str)
+        status (str): from ProgramCourseEnrollmenStatuses
+
+    Returns: CourseEnrollment
+
+    Raises: NonExistentCourseError
+    """
+    _ensure_course_exists(course_key, user.id)
+    if status not in ProgramCourseEnrollmentStatuses.__ALL__:
+        raise ValueError("invalid ProgramCourseEnrollmenStatus: {}".format(status))
+    if CourseEnrollment.is_enrolled(user, course_key):
+        course_enrollment = CourseEnrollment.objects.get(
+            user=user,
+            course_id=course_key,
+        )
+        if course_enrollment.mode in {CourseMode.AUDIT, CourseMode.HONOR}:
+            course_enrollment.mode = CourseMode.MASTERS
+            course_enrollment.save()
+            message_template = (
+                "Converted course enrollment for user id={} "
+                "and course key={} from mode {} to Master's."
+            )
+            logger.info(
+                message_template.format(user.id, course_key, course_enrollment.mode)
+            )
+        elif course_enrollment.mode != CourseMode.MASTERS:
+            error_message = (
+                "Cannot convert CourseEnrollment to Master's from mode {}. "
+                "user id={}, course_key={}."
+            ).format(
+                course_enrollment.mode, user.id, course_key
+            )
+            logger.error(error_message)
+    else:
+        course_enrollment = CourseEnrollment.enroll(
+            user,
+            course_key,
+            mode=CourseMode.MASTERS,
+            check_access=False,
+        )
+    if course_enrollment.mode == CourseMode.MASTERS:
+        if status == ProgramCourseEnrollmentStatuses.INACTIVE:
+            course_enrollment.deactivate()
+    return course_enrollment
+
+
+def _ensure_course_exists(course_key, user_key_or_id):
+    """
+    Log and raise an error if `course_key` does not refer to a real course run.
+
+    `user_key_or_id` should be a non-PII value identifying the user that
+    can be used in the log message.
+    """
+    if CourseOverview.course_exists(course_key):
+        return
+    logger.error(
+        "Cannot enroll user={} in non-existent course={}".format(
+            user_key_or_id,
+            course_key,
+        )
+    )
+    raise NonExistentCourseError
+
+
+def _organize_requests_by_external_key(enrollment_requests):
+    """
+    Get dict of enrollment requests by external key.
+    External keys associated with more than one request are split out into a set,
+        and their enrollment requests thrown away.
+
+    Arguments:
+        enrollment_requests (list[dict])
+
+    Returns:
+        (requests_by_key, duplicated_keys)
+        where requests_by_key is dict[str: dict]
+          and duplicated_keys is set[str].
+    """
+    requests_by_key = {}
+    duplicated_keys = set()
+    for request in enrollment_requests:
+        key = request['external_user_key']
+        if key in duplicated_keys:
+            continue
+        if key in requests_by_key:
+            duplicated_keys.add(key)
+            del requests_by_key[key]
+            continue
+        requests_by_key[key] = request
+    return requests_by_key, duplicated_keys
diff --git a/lms/djangoapps/program_enrollments/constants.py b/lms/djangoapps/program_enrollments/constants.py
index 103a5f1e2e188f95f98be8297fa19ac5e2c655bb..6b1e0efaeb6adb8cab8d6ff1d5244427d80fca60 100644
--- a/lms/djangoapps/program_enrollments/constants.py
+++ b/lms/djangoapps/program_enrollments/constants.py
@@ -40,3 +40,76 @@ class ProgramCourseEnrollmentStatuses(object):
     __MODEL_CHOICES__ = (
         (status, status) for status in __ALL__
     )
+
+
+class _EnrollmentErrorStatuses(object):
+    """
+    Error statuses common to program and program-course enrollments responses.
+    """
+
+    # Same student key supplied more than once.
+    DUPLICATED = 'duplicated'
+
+    # Requested target status is invalid
+    INVALID_STATUS = "invalid-status"
+
+    # In the case of a POST request, the enrollment already exists.
+    CONFLICT = "conflict"
+
+    # Although the request is syntactically valid,
+    # the change being made is not supported.
+    # For example, it may be illegal to change a user's status back to A
+    # after changing it to B, where A and B are two hypothetical enrollment
+    # statuses.
+    ILLEGAL_OPERATION = "illegal-operation"
+
+    # Could not modify program enrollment or create program-course
+    # enrollment because the student is not enrolled in the program in the
+    # first place.
+    NOT_IN_PROGRAM = "not-in-program"
+
+    # Something unexpected went wrong.
+    # If API users are seeing this, we need to investigate.
+    INTERNAL_ERROR = "internal-error"
+
+    __ALL__ = (
+        DUPLICATED,
+        INVALID_STATUS,
+        CONFLICT,
+        ILLEGAL_OPERATION,
+        NOT_IN_PROGRAM,
+        INTERNAL_ERROR,
+    )
+
+
+class ProgramOperationStatuses(
+        ProgramEnrollmentStatuses,
+        _EnrollmentErrorStatuses,
+):
+    """
+    Valid program enrollment operation statuses.
+
+    Combines error statuses and OK statuses.
+    """
+    __OK__ = ProgramEnrollmentStatuses.__ALL__
+    __ERRORS__ = _EnrollmentErrorStatuses.__ALL__
+    __ALL__ = __OK__ + __ERRORS__
+
+
+class ProgramCourseOperationStatuses(
+        ProgramCourseEnrollmentStatuses,
+        _EnrollmentErrorStatuses,
+):
+    """
+    Valid program-course enrollment operation statuses.
+
+    Combines error statuses and OK statuses.
+    """
+
+    # Could not modify program-course enrollment because the user
+    # is not enrolled in the course in the first place.
+    NOT_FOUND = "not-found"
+
+    __OK__ = ProgramCourseEnrollmentStatuses.__ALL__
+    __ERRORS__ = (NOT_FOUND,) + _EnrollmentErrorStatuses.__ALL__
+    __ALL__ = __OK__ + __ERRORS__
diff --git a/lms/djangoapps/program_enrollments/exceptions.py b/lms/djangoapps/program_enrollments/exceptions.py
new file mode 100644
index 0000000000000000000000000000000000000000..5ef09ee7c912aa71f1228ec7955c9f8784a2847b
--- /dev/null
+++ b/lms/djangoapps/program_enrollments/exceptions.py
@@ -0,0 +1,65 @@
+"""
+Exceptions raised by functions exposed by program_enrollments Django app.
+"""
+from __future__ import absolute_import, unicode_literals
+
+# Every `__init__` here calls empty Exception() constructor.
+# pylint: disable=super-init-not-called
+
+
+class ProgramDoesNotExistException(Exception):
+
+    def __init__(self, program_uuid):
+        self.program_uuid = program_uuid
+
+    def __str__(self):
+        return 'Unable to find catalog program matching uuid {}'.format(self.program_uuid)
+
+
+class OrganizationDoesNotExistException(Exception):
+    pass
+
+
+class ProgramHasNoAuthoringOrganizationException(OrganizationDoesNotExistException):
+
+    def __init__(self, program_uuid):
+        self.program_uuid = program_uuid
+
+    def __str__(self):
+        return (
+            'Cannot determine authoring organization key for catalog program {}'
+        ).format(self.program_uuid)
+
+
+class BadOrganizationShortNameException(OrganizationDoesNotExistException):
+
+    def __init__(self, organization_short_name):
+        self.organization_short_name = organization_short_name
+
+    def __str__(self):
+        return 'Unable to find organization for short_name {}'.format(
+            self.organization_short_name
+        )
+
+
+class ProviderDoesNotExistException(Exception):
+
+    def __init__(self, organization):
+        self.organization = organization
+
+    def __str__(self):
+        return 'Unable to find organization for short_name {}'.format(
+            self.organization.id
+        )
+
+
+class ProviderConfigurationException(Exception):
+
+    def __init__(self, organization):
+        self.organization = organization
+
+    def __str__(self):
+        return (
+            'Multiple active SAML configurations found for organization={}. '
+            'Expected one.'
+        ).format(self.organization.short_name)
diff --git a/lms/djangoapps/program_enrollments/management/commands/link_program_enrollments.py b/lms/djangoapps/program_enrollments/management/commands/link_program_enrollments.py
index fd841411d061ee2690f6f71a7e0ec69be60f345d..62758a7785d9e8f08bc727a3f26adf341fe3e019 100644
--- a/lms/djangoapps/program_enrollments/management/commands/link_program_enrollments.py
+++ b/lms/djangoapps/program_enrollments/management/commands/link_program_enrollments.py
@@ -1,18 +1,18 @@
 """ Management command to link program enrollments and external student_keys to an LMS user """
 from __future__ import absolute_import, unicode_literals
 
-import logging
+from uuid import UUID
 
 from django.contrib.auth import get_user_model
 from django.core.management.base import BaseCommand, CommandError
 
-from lms.djangoapps.program_enrollments.api import link_program_enrollments_to_lms_users
+from lms.djangoapps.program_enrollments.api import link_program_enrollments
 
-logger = logging.getLogger(__name__)
 User = get_user_model()
 
 INCORRECT_PARAMETER_TEMPLATE = (
-    'incorrectly formatted argument {}, must be in form <external user key>:<lms username>'
+    "incorrectly formatted argument '{}', "
+    "must be in form <external user key>:<lms username>"
 )
 DUPLICATE_KEY_TEMPLATE = 'external user key {} provided multiple times'
 
@@ -70,11 +70,17 @@ class Command(BaseCommand):
 
     # pylint: disable=arguments-differ
     def handle(self, program_uuid, user_items, *args, **options):
+        try:
+            parsed_program_uuid = UUID(program_uuid)
+        except ValueError:
+            raise CommandError("supplied program_uuid '{}' is not a valid UUID")
         ext_keys_to_usernames = self.parse_user_items(user_items)
         try:
-            link_program_enrollments_to_lms_users(program_uuid, ext_keys_to_usernames)
+            link_program_enrollments(
+                parsed_program_uuid, ext_keys_to_usernames
+            )
         except Exception as e:
-            raise CommandError(e)
+            raise CommandError(str(e))
 
     def parse_user_items(self, user_items):
         """
@@ -82,18 +88,21 @@ class Command(BaseCommand):
             list of strings in the format 'external_user_key:lms_username'
         Returns:
             dict mapping external user keys to lms usernames
+        Raises:
+            CommandError
         """
         result = {}
         for user_item in user_items:
             split_args = user_item.split(':')
             if len(split_args) != 2:
-                message = (INCORRECT_PARAMETER_TEMPLATE).format(user_item)
+                message = INCORRECT_PARAMETER_TEMPLATE.format(user_item)
+                raise CommandError(message)
+            external_user_key = split_args[0].strip()
+            lms_username = split_args[1].strip()
+            if not (external_user_key and lms_username):
+                message = INCORRECT_PARAMETER_TEMPLATE.format(user_item)
                 raise CommandError(message)
-
-            external_user_key = split_args[0]
-            lms_username = split_args[1]
             if external_user_key in result:
                 raise CommandError(DUPLICATE_KEY_TEMPLATE.format(external_user_key))
-
             result[external_user_key] = lms_username
         return result
diff --git a/lms/djangoapps/program_enrollments/management/commands/tests/test_link_program_enrollments.py b/lms/djangoapps/program_enrollments/management/commands/tests/test_link_program_enrollments.py
index 2f4c899a1e671f2a3f2dfaf3dc59f75faf104d0e..262fd7a6f7cb63cc3d723b8464b324b3f6e38d42 100644
--- a/lms/djangoapps/program_enrollments/management/commands/tests/test_link_program_enrollments.py
+++ b/lms/djangoapps/program_enrollments/management/commands/tests/test_link_program_enrollments.py
@@ -3,6 +3,8 @@ Tests for the link_program_enrollments management command.
 """
 from __future__ import absolute_import
 
+from uuid import UUID
+
 import mock
 from django.core.management import call_command
 from django.core.management.base import CommandError
@@ -15,13 +17,13 @@ _COMMAND_PATH = 'lms.djangoapps.program_enrollments.management.commands.link_pro
 
 class TestLinkProgramEnrollmentManagementCommand(TestCase):
     """
-    Test that the command calls link_program_enrollments_to_lms_users
+    Test that the command calls link_program_enrollments
     correctly and handles exceptional input correctly.
     """
 
     program_uuid = 'a32c5da8-fb89-4f1e-97a7-b13de9e6dfa2'
 
-    _LINKING_FUNCTION_MOCK_PATH = _COMMAND_PATH + ".link_program_enrollments_to_lms_users"
+    _LINKING_FUNCTION_MOCK_PATH = _COMMAND_PATH + ".link_program_enrollments"
 
     @mock.patch(_LINKING_FUNCTION_MOCK_PATH, autospec=True)
     def test_good_input_calls_linking(self, mock_link):
@@ -29,7 +31,7 @@ class TestLinkProgramEnrollmentManagementCommand(TestCase):
             Command(), self.program_uuid, 'learner-01:user-01', 'learner-02:user-02'
         )
         mock_link.assert_called_once_with(
-            self.program_uuid,
+            UUID(self.program_uuid),
             {
                 'learner-01': 'user-01',
                 'learner-02': 'user-02',
@@ -45,6 +47,24 @@ class TestLinkProgramEnrollmentManagementCommand(TestCase):
                 Command(), self.program_uuid, 'learner-01:user-01', 'whoops', 'learner-03:user-03'
             )
 
+    def test_missing_external_user_key(self):
+        with self.assertRaisesRegex(
+                CommandError,
+                INCORRECT_PARAMETER_TEMPLATE.format('whoops: ')
+        ):
+            call_command(
+                Command(), self.program_uuid, 'learner-01:user-01', 'whoops: ', 'learner-03:user-03'
+            )
+
+    def test_missing_username(self):
+        with self.assertRaisesRegex(
+                CommandError,
+                INCORRECT_PARAMETER_TEMPLATE.format(' :whoops')
+        ):
+            call_command(
+                Command(), self.program_uuid, 'learner-01:user-01', ' :whoops', 'learner-03:user-03'
+            )
+
     def test_repeated_user_key_exception(self):
         with self.assertRaisesRegex(
                 CommandError,
@@ -53,3 +73,10 @@ class TestLinkProgramEnrollmentManagementCommand(TestCase):
             call_command(
                 Command(), self.program_uuid, 'learner-01:user-01', 'learner-01:user-02'
             )
+
+    def test_invalid_uuid(self):
+        error_regex = r"supplied program_uuid '.*' is not a valid UUID"
+        with self.assertRaisesRegex(CommandError, error_regex):
+            call_command(
+                Command(), 'notauuid::thisisntauuid', 'learner-0:user-01'
+            )
diff --git a/lms/djangoapps/program_enrollments/models.py b/lms/djangoapps/program_enrollments/models.py
index 7b89e39aac24973e969bfc80b71830d1b9772f28..06ff34f2a5f327da3aedd0603e7b08029916f2d7 100644
--- a/lms/djangoapps/program_enrollments/models.py
+++ b/lms/djangoapps/program_enrollments/models.py
@@ -4,8 +4,6 @@ Django model specifications for the Program Enrollments API
 """
 from __future__ import absolute_import, unicode_literals
 
-import logging
-
 from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
 from django.db import models
@@ -13,16 +11,11 @@ from django.utils.translation import ugettext_lazy as _
 from model_utils.models import TimeStampedModel
 from opaque_keys.edx.django.models import CourseKeyField
 from simple_history.models import HistoricalRecords
-from six import text_type
 
-from course_modes.models import CourseMode
-from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
-from student.models import CourseEnrollment, NonExistentCourseError
+from student.models import CourseEnrollment
 
 from .constants import ProgramCourseEnrollmentStatuses, ProgramEnrollmentStatuses
 
-logger = logging.getLogger(__name__)  # pylint: disable=invalid-name
-
 
 class ProgramEnrollment(TimeStampedModel):  # pylint: disable=model-missing-unicode
     """
@@ -82,17 +75,6 @@ class ProgramEnrollment(TimeStampedModel):  # pylint: disable=model-missing-unic
         enrollments.update(external_user_key=None)
         return True
 
-    def get_program_course_enrollment(self, course_key):
-        """
-        Returns the ProgramCourseEnrollment associated with this ProgramEnrollment and given course,
-         None if it does not exist
-        """
-        try:
-            program_course_enrollment = self.program_course_enrollments.get(course_key=course_key)
-        except ProgramCourseEnrollment.DoesNotExist:
-            return None
-        return program_course_enrollment
-
     def __str__(self):
         return '[ProgramEnrollment id={}]'.format(self.id)
 
@@ -136,89 +118,3 @@ class ProgramCourseEnrollment(TimeStampedModel):  # pylint: disable=model-missin
 
     def __str__(self):
         return '[ProgramCourseEnrollment id={}]'.format(self.id)
-
-    @classmethod
-    def create_program_course_enrollment(cls, program_enrollment, course_key, status):
-        """
-        Create ProgramCourseEnrollment for the given course and program enrollment
-        """
-        program_course_enrollment = ProgramCourseEnrollment.objects.create(
-            program_enrollment=program_enrollment,
-            course_key=course_key,
-            status=status,
-        )
-
-        if program_enrollment.user:
-            program_course_enrollment.enroll(program_enrollment.user)
-
-        return program_course_enrollment.status
-
-    def change_status(self, status):
-        """
-        Modify ProgramCourseEnrollment status and course_enrollment status if it exists
-        """
-        if status == self.status:
-            return status
-
-        self.status = status
-        if self.course_enrollment:
-            if status == ProgramCourseEnrollmentStatuses.ACTIVE:
-                self.course_enrollment.activate()
-            elif status == ProgramCourseEnrollmentStatuses.INACTIVE:
-                self.course_enrollment.deactivate()
-            else:
-                message = ("Changed {enrollment} status to {status}, not changing course_enrollment"
-                           " status because status is not '{active}' or '{inactive}'")
-                logger.warn(message.format(
-                    enrollment=self,
-                    status=status,
-                    active=ProgramCourseEnrollmentStatuses.ACTIVE,
-                    inactive=ProgramCourseEnrollmentStatuses.INACTIVE
-                ))
-        elif self.program_enrollment.user:
-            logger.warn("User {user} {program_enrollment} {course_key} has no course_enrollment".format(
-                user=self.program_enrollment.user,
-                program_enrollment=self.program_enrollment,
-                course_key=self.course_key,
-            ))
-        self.save()
-        return self.status
-
-    def enroll(self, user):
-        """
-        Create a CourseEnrollment to enroll user in course
-        """
-        try:
-            CourseOverview.get_from_id(self.course_key)
-        except CourseOverview.DoesNotExist:
-            logger.warning(
-                "User %s failed to enroll in non-existent course %s", user.id,
-                text_type(self.course_key),
-            )
-            raise NonExistentCourseError
-
-        if CourseEnrollment.is_enrolled(user, self.course_key):
-            course_enrollment = CourseEnrollment.objects.get(
-                user=user,
-                course_id=self.course_key,
-            )
-            if course_enrollment.mode in {CourseMode.AUDIT, CourseMode.HONOR}:
-                course_enrollment.mode = CourseMode.MASTERS
-                course_enrollment.save()
-            self.course_enrollment = course_enrollment
-            message_template = (
-                "Attempted to create course enrollment for user={user} "
-                "and course={course} but an enrollment already exists. "
-                "Existing enrollment will be used instead."
-            )
-            logger.info(message_template.format(user=user.id, course=self.course_key))
-        else:
-            self.course_enrollment = CourseEnrollment.enroll(
-                user,
-                self.course_key,
-                mode=CourseMode.MASTERS,
-                check_access=False,
-            )
-        if self.status == ProgramCourseEnrollmentStatuses.INACTIVE:
-            self.course_enrollment.deactivate()
-        self.save()
diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/constants.py b/lms/djangoapps/program_enrollments/rest_api/v1/constants.py
index 25239fa7d09e2b441da9b500300f4a3eeb8a3b64..9b81884e39dde30267690d772d2a432b279880e9 100644
--- a/lms/djangoapps/program_enrollments/rest_api/v1/constants.py
+++ b/lms/djangoapps/program_enrollments/rest_api/v1/constants.py
@@ -3,8 +3,6 @@ Constants used throughout the program_enrollments V1 API.
 """
 from __future__ import absolute_import, unicode_literals
 
-from lms.djangoapps.program_enrollments.constants import ProgramCourseEnrollmentStatuses, ProgramEnrollmentStatuses
-
 # Captures strings composed of alphanumeric characters a-f and dashes.
 PROGRAM_UUID_PATTERN = r'(?P<program_uuid>[A-Fa-f0-9-]+)'
 
@@ -19,79 +17,6 @@ REQUEST_STUDENT_KEY = 'student_key'
 ENABLE_ENROLLMENT_RESET_FLAG = 'ENABLE_ENROLLMENT_RESET'
 
 
-class _EnrollmentErrorStatuses(object):
-    """
-    Error statuses common to program and program-course enrollments responses.
-    """
-
-    # Same student key supplied more than once.
-    DUPLICATED = 'duplicated'
-
-    # Requested target status is invalid
-    INVALID_STATUS = "invalid-status"
-
-    # In the case of a POST request, the enrollment already exists.
-    CONFLICT = "conflict"
-
-    # Although the request is syntactically valid,
-    # the change being made is not supported.
-    # For example, it may be illegal to change a user's status back to A
-    # after changing it to B, where A and B are two hypothetical enrollment
-    # statuses.
-    ILLEGAL_OPERATION = "illegal-operation"
-
-    # Could not modify program enrollment or create program-course
-    # enrollment because the student is not enrolled in the program in the
-    # first place.
-    NOT_IN_PROGRAM = "not-in-program"
-
-    # Something unexpected went wrong.
-    # If API users are seeing this, we need to investigate.
-    INTERNAL_ERROR = "internal-error"
-
-    __ALL__ = (
-        DUPLICATED,
-        INVALID_STATUS,
-        CONFLICT,
-        ILLEGAL_OPERATION,
-        NOT_IN_PROGRAM,
-        INTERNAL_ERROR,
-    )
-
-
-class ProgramResponseStatuses(
-        ProgramEnrollmentStatuses,
-        _EnrollmentErrorStatuses,
-):
-    """
-    Valid program enrollment response statuses.
-
-    Combines error statuses and OK statuses.
-    """
-    __OK__ = ProgramEnrollmentStatuses.__ALL__
-    __ERRORS__ = _EnrollmentErrorStatuses.__ALL__
-    __ALL__ = __OK__ + __ERRORS__
-
-
-class ProgramCourseResponseStatuses(
-        ProgramCourseEnrollmentStatuses,
-        _EnrollmentErrorStatuses,
-):
-    """
-    Valid program-course enrollment response statuses.
-
-    Combines error statuses and OK statuses.
-    """
-
-    # Could not modify program-course enrollment because the user
-    # is not enrolled in the course in the first place.
-    NOT_FOUND = "not-found"
-
-    __OK__ = ProgramCourseEnrollmentStatuses.__ALL__
-    __ERRORS__ = (NOT_FOUND,) + _EnrollmentErrorStatuses.__ALL__
-    __ALL__ = __OK__ + __ERRORS__
-
-
 class CourseRunProgressStatuses(object):
     """
     Statuses that a course run can be in with respect to user progress.
diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/serializers.py b/lms/djangoapps/program_enrollments/rest_api/v1/serializers.py
index fe1a2d63fbb107031747e0b4c3d5f71df4b863b6..758ff8f25145a58e324f00dd345bfb9bc4adcd4d 100644
--- a/lms/djangoapps/program_enrollments/rest_api/v1/serializers.py
+++ b/lms/djangoapps/program_enrollments/rest_api/v1/serializers.py
@@ -6,7 +6,6 @@ from __future__ import absolute_import, unicode_literals
 from rest_framework import serializers
 from six import text_type
 
-from lms.djangoapps.program_enrollments.constants import ProgramCourseEnrollmentStatuses, ProgramEnrollmentStatuses
 from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
 
 from .constants import CourseRunProgressStatuses
@@ -45,6 +44,32 @@ class ProgramEnrollmentSerializer(serializers.Serializer):
         return bool(obj.user)
 
 
+class ProgramEnrollmentRequestMixin(InvalidStatusMixin, serializers.Serializer):
+    """
+    Base fields for all program enrollment related serializers.
+    """
+    student_key = serializers.CharField(allow_blank=False, source='external_user_key')
+    # We could have made this a ChoiceField on ProgramEnrollmentStatuses.__ALL__;
+    # however, we instead check statuses in api/writing.py,
+    # returning INVALID_STATUS for individual bad statuses instead of raising
+    # a ValidationError for the entire request.
+    status = serializers.CharField(allow_blank=False)
+
+
+class ProgramEnrollmentCreateRequestSerializer(ProgramEnrollmentRequestMixin):
+    """
+    Serializer for program enrollment creation requests.
+    """
+    curriculum_uuid = serializers.UUIDField()
+
+
+class ProgramEnrollmentUpdateRequestSerializer(ProgramEnrollmentRequestMixin):
+    """
+    Serializer for program enrollment update requests.
+    """
+    pass
+
+
 class ProgramCourseEnrollmentSerializer(serializers.Serializer):
     """
     Serializer for displaying program-course enrollments.
@@ -67,40 +92,16 @@ class ProgramCourseEnrollmentSerializer(serializers.Serializer):
         return text_type(obj.program_enrollment.curriculum_uuid)
 
 
-class ProgramEnrollmentRequestMixin(InvalidStatusMixin, serializers.Serializer):
-    """
-    Base fields for all program enrollment related serializers.
-    """
-    student_key = serializers.CharField()
-    status = serializers.ChoiceField(
-        allow_blank=False,
-        choices=ProgramEnrollmentStatuses.__ALL__,
-    )
-
-
-class ProgramEnrollmentCreateRequestSerializer(ProgramEnrollmentRequestMixin):
-    """
-    Serializer for program enrollment creation requests.
-    """
-    curriculum_uuid = serializers.UUIDField()
-
-
-class ProgramEnrollmentModifyRequestSerializer(ProgramEnrollmentRequestMixin):
-    """
-    Serializer for program enrollment modification requests
-    """
-    pass
-
-
 class ProgramCourseEnrollmentRequestSerializer(serializers.Serializer, InvalidStatusMixin):
     """
     Serializer for request to create a ProgramCourseEnrollment
     """
-    student_key = serializers.CharField(allow_blank=False)
-    status = serializers.ChoiceField(
-        allow_blank=False,
-        choices=ProgramCourseEnrollmentStatuses.__ALL__,
-    )
+    student_key = serializers.CharField(allow_blank=False, source='external_user_key')
+    # We could have made this a ChoiceField on ProgramCourseEnrollmentStatuses.__ALL__;
+    # however, we instead check statuses in api/writing.py,
+    # returning INVALID_STATUS for individual bad statuses instead of raising
+    # a ValidationError for the entire request.
+    status = serializers.CharField(allow_blank=False)
 
 
 class ProgramCourseGradeSerializer(serializers.Serializer):
@@ -110,7 +111,7 @@ class ProgramCourseGradeSerializer(serializers.Serializer):
     Meant to be used with BaseProgramCourseGrade.
     """
     # Required
-    student_key = serializers.CharField()
+    student_key = serializers.SerializerMethodField()
 
     # From ProgramCourseGradeOk only
     passed = serializers.BooleanField(required=False)
@@ -120,6 +121,9 @@ class ProgramCourseGradeSerializer(serializers.Serializer):
     # From ProgramCourseGradeError only
     error = serializers.CharField(required=False)
 
+    def get_student_key(self, obj):
+        return obj.program_course_enrollment.program_enrollment.external_user_key
+
 
 class DueDateSerializer(serializers.Serializer):
     """
@@ -158,62 +162,3 @@ class CourseRunOverviewListSerializer(serializers.Serializer):
     Serializer for a list of course run overviews.
     """
     course_runs = serializers.ListField(child=CourseRunOverviewSerializer())
-
-
-# TODO: The following classes are not serializers, and should probably
-# be moved to api.py as part of EDUCATOR-4321.
-
-
-class BaseProgramCourseGrade(object):
-    """
-    Base for either a courserun grade or grade-loading failure.
-
-    Can be passed to ProgramCourseGradeResultSerializer.
-    """
-    is_error = None  # Override in subclass
-
-    def __init__(self, program_course_enrollment):
-        """
-        Given a ProgramCourseEnrollment,
-        create a BaseProgramCourseGradeResult instance.
-        """
-        self.student_key = (
-            program_course_enrollment.program_enrollment.external_user_key
-        )
-
-
-class ProgramCourseGradeOk(BaseProgramCourseGrade):
-    """
-    Represents a courserun grade for a user enrolled through a program.
-    """
-    is_error = False
-
-    def __init__(self, program_course_enrollment, course_grade):
-        """
-        Given a ProgramCourseEnrollment and course grade object,
-        create a ProgramCourseGradeOk.
-        """
-        super(ProgramCourseGradeOk, self).__init__(
-            program_course_enrollment
-        )
-        self.passed = course_grade.passed
-        self.percent = course_grade.percent
-        self.letter_grade = course_grade.letter_grade
-
-
-class ProgramCourseGradeError(BaseProgramCourseGrade):
-    """
-    Represents a failure to load a courserun grade for a user enrolled through
-    a program.
-    """
-    is_error = True
-
-    def __init__(self, program_course_enrollment, exception=None):
-        """
-        Given a ProgramCourseEnrollment and an Exception,
-        create a ProgramCourseGradeError.
-        """
-        super(ProgramCourseGradeError, self).__init__(
-            program_course_enrollment
-        )
-        self.error = text_type(exception) if exception else "Unknown error"
diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/tests/test_views.py b/lms/djangoapps/program_enrollments/rest_api/v1/tests/test_views.py
index db316cc566dfbd69219208fbff19a00fc1abf36a..0b91f00ce543c0e39beb1e5770fb8caa1be5d6ea 100644
--- a/lms/djangoapps/program_enrollments/rest_api/v1/tests/test_views.py
+++ b/lms/djangoapps/program_enrollments/rest_api/v1/tests/test_views.py
@@ -4,6 +4,7 @@ Unit tests for ProgramEnrollment views.
 from __future__ import absolute_import, unicode_literals
 
 import json
+from collections import defaultdict
 from datetime import datetime, timedelta
 from uuid import UUID, uuid4
 
@@ -28,9 +29,12 @@ from course_modes.models import CourseMode
 from lms.djangoapps.certificates.models import CertificateStatuses
 from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
 from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, InstructorFactory
+from lms.djangoapps.grades.api import CourseGradeFactory
+from lms.djangoapps.program_enrollments.constants import ProgramCourseOperationStatuses as CourseStatuses
+from lms.djangoapps.program_enrollments.constants import ProgramOperationStatuses as ProgramStatuses
+from lms.djangoapps.program_enrollments.exceptions import ProviderDoesNotExistException
 from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
 from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
-from lms.djangoapps.program_enrollments.utils import ProviderDoesNotExistException
 from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL, PROGRAMS_BY_ORGANIZATION_CACHE_KEY_TPL
 from openedx.core.djangoapps.catalog.tests.factories import (
     CourseFactory,
@@ -55,11 +59,18 @@ from ..constants import (
     REQUEST_STUDENT_KEY,
     CourseRunProgressStatuses
 )
-from ..constants import ProgramCourseResponseStatuses as CourseStatuses
-from ..constants import ProgramResponseStatuses as ProgramStatuses
 
-_REST_API_MOCK_FMT = 'lms.djangoapps.program_enrollments.rest_api.{}'
-_VIEW_MOCK_FMT = _REST_API_MOCK_FMT.format('v1.views.{}')
+_DJANGOAPP_PATCH_FORMAT = 'lms.djangoapps.program_enrollments.{}'
+_REST_API_PATCH_FORMAT = _DJANGOAPP_PATCH_FORMAT.format('rest_api.v1.{}')
+_VIEW_PATCH_FORMAT = _REST_API_PATCH_FORMAT.format('views.{}')
+
+
+_get_users_patch_path = _DJANGOAPP_PATCH_FORMAT.format('api.writing.get_users_by_external_keys')
+_patch_get_users = mock.patch(
+    _get_users_patch_path,
+    autospec=True,
+    return_value=defaultdict(lambda: None),
+)
 
 
 class ProgramCacheMixin(CacheIsolationMixin):
@@ -86,8 +97,9 @@ class EnrollmentsDataMixin(ProgramCacheMixin):
     def setUpClass(cls):
         super(EnrollmentsDataMixin, cls).setUpClass()
         cls.start_cache_isolation()
-        cls.organization_key = "orgkey"
+        cls.organization_key = "testorg"
         catalog_org = OrganizationFactory(key=cls.organization_key)
+        LMSOrganizationFactory(short_name=cls.organization_key)
         cls.program_uuid = UUID('00000000-1111-2222-3333-444444444444')
         cls.program_uuid_tmpl = '00000000-1111-2222-3333-4444444444{0:02d}'
         cls.curriculum_uuid = UUID('aaaaaaaa-1111-2222-3333-444444444444')
@@ -334,7 +346,6 @@ class ProgramEnrollmentsGetTests(EnrollmentsDataMixin, APITestCase):
 class ProgramEnrollmentsWriteMixin(EnrollmentsDataMixin):
     """ Mixin class that defines common tests for program enrollment write endpoints """
     add_uuid = False
-    success_status = 200
 
     view_name = 'programs_api:v1:program_enrollments'
 
@@ -381,8 +392,7 @@ class ProgramEnrollmentsWriteMixin(EnrollmentsDataMixin):
             json.dumps([{'status': 'enrolled'}]),
             content_type='application/json'
         )
-        self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
-        self.assertEqual(response.data, 'invalid enrollment record')
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
     def test_program_unauthorized(self):
         student = UserFactory.create(password='password')
@@ -414,22 +424,17 @@ class ProgramEnrollmentsWriteMixin(EnrollmentsDataMixin):
 
         response = self.request(url, json.dumps(enrollments), content_type='application/json')
 
-        self.assertEqual(422, response.status_code)
-        self.assertEqual('invalid enrollment record', response.data)
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
     def test_extra_field(self):
         self.student_enrollment('pending', 'learner-01', prepare_student=True)
         enrollment = self.student_enrollment('enrolled', 'learner-01')
         enrollment['favorite_pokemon'] = 'bulbasaur'
         enrollments = [enrollment]
-        with mock.patch(
-            _VIEW_MOCK_FMT.format('get_user_by_program_id'),
-            autospec=True,
-            return_value=None
-        ):
+        with _patch_get_users:
             url = self.get_url()
             response = self.request(url, json.dumps(enrollments), content_type='application/json')
-        self.assertEqual(self.success_status, response.status_code)
+        self.assertEqual(200, response.status_code)
         self.assertDictEqual(
             response.data,
             {'learner-01': 'enrolled'}
@@ -442,8 +447,6 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
     Tests for the ProgramEnrollment view POST method.
     """
     add_uuid = True
-    success_status = status.HTTP_201_CREATED
-    success_status = 201
 
     view_name = 'programs_api:v1:program_enrollments'
 
@@ -470,14 +473,10 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
         ]
 
         url = self.get_url(program_uuid=0)
-        with mock.patch(
-            _VIEW_MOCK_FMT.format('get_user_by_program_id'),
-            autospec=True,
-            return_value=None
-        ):
+        with _patch_get_users:
             response = self.client.post(url, json.dumps(post_data), content_type='application/json')
 
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(response.status_code, 200)
 
         for i in range(3):
             enrollment = ProgramEnrollment.objects.get(external_user_key=external_user_keys[i])
@@ -499,12 +498,14 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
         user = User.objects.create_user('test_user', 'test@example.com', 'password')
         url = self.get_url()
         with mock.patch(
-            _VIEW_MOCK_FMT.format('get_user_by_program_id'),
-            autospec=True,
-            return_value=user
+                _get_users_patch_path,
+                autospec=True,
+                return_value={'abc1': user},
         ):
-            response = self.client.post(url, json.dumps(post_data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+            response = self.client.post(
+                url, json.dumps(post_data), content_type='application/json'
+            )
+        self.assertEqual(response.status_code, 200)
         enrollment = ProgramEnrollment.objects.get(external_user_key='abc1')
         self.assertEqual(enrollment.external_user_key, 'abc1')
         self.assertEqual(enrollment.program_uuid, self.program_uuid)
@@ -523,13 +524,13 @@ class ProgramEnrollmentsPostTests(ProgramEnrollmentsWriteMixin, APITestCase):
 
         url = self.get_url()
         with mock.patch(
-            _VIEW_MOCK_FMT.format('get_user_by_program_id'),
-            autospec=True,
-            side_effect=ProviderDoesNotExistException()
+                _get_users_patch_path,
+                autospec=True,
+                side_effect=ProviderDoesNotExistException(None),
         ):
             response = self.client.post(url, json.dumps(post_data), content_type='application/json')
 
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(response.status_code, 200)
 
         for i in range(3):
             enrollment = ProgramEnrollment.objects.get(external_user_key='abc{}'.format(i))
@@ -546,7 +547,6 @@ class ProgramEnrollmentsPatchTests(ProgramEnrollmentsWriteMixin, APITestCase):
     Tests for the ProgramEnrollment view PATCH method.
     """
     add_uuid = False
-    success_status = status.HTTP_200_OK
 
     def setUp(self):
         super(ProgramEnrollmentsPatchTests, self).setUp()
@@ -692,19 +692,11 @@ class ProgramEnrollmentsPutTests(ProgramEnrollmentsWriteMixin, APITestCase):
     Tests for the ProgramEnrollment view PATCH method.
     """
     add_uuid = True
-    success_status = status.HTTP_200_OK
 
     def setUp(self):
         super(ProgramEnrollmentsPutTests, self).setUp()
         self.request = self.client.put
         self.client.login(username=self.global_staff.username, password='password')
-        patch_get_user = mock.patch(
-            _VIEW_MOCK_FMT.format('get_user_by_program_id'),
-            autospec=True,
-            return_value=None
-        )
-        self.mock_get_user = patch_get_user.start()
-        self.addCleanup(patch_get_user.stop)
 
     def prepare_student(self, key):
         ProgramEnrollment.objects.create(
@@ -730,8 +722,11 @@ class ProgramEnrollmentsPutTests(ProgramEnrollmentsWriteMixin, APITestCase):
                 )
 
         url = self.get_url()
-        response = self.client.put(url, json.dumps(request_data), content_type='application/json')
-        self.assertEqual(self.success_status, response.status_code)
+        with _patch_get_users:
+            response = self.client.put(
+                url, json.dumps(request_data), content_type='application/json'
+            )
+        self.assertEqual(200, response.status_code)
         self.assertEqual(5, len(response.data))
         for response_status in response.data.values():
             self.assertEqual(response_status, ProgramStatuses.ENROLLED)
@@ -755,8 +750,11 @@ class ProgramEnrollmentsPutTests(ProgramEnrollmentsWriteMixin, APITestCase):
         )
 
         url = self.get_url()
-        response = self.client.put(url, json.dumps(request_data), content_type='application/json')
-        self.assertEqual(self.success_status, response.status_code)
+        with _patch_get_users:
+            response = self.client.put(
+                url, json.dumps(request_data), content_type='application/json'
+            )
+        self.assertEqual(200, response.status_code)
         self.assertEqual(4, len(response.data))
         for response_status in response.data.values():
             self.assertEqual(response_status, ProgramStatuses.ENROLLED)
@@ -870,6 +868,7 @@ class ProgramCourseEnrollmentsMixin(EnrollmentsDataMixin):
         )
 
     def test_invalid_status(self):
+        self.prepare_student('learner-1')
         request_data = [self.learner_enrollment('learner-1', 'this-is-not-a-status')]
         response = self.request(self.default_url, request_data)
         self.assertEqual(422, response.status_code)
@@ -885,7 +884,6 @@ class ProgramCourseEnrollmentsMixin(EnrollmentsDataMixin):
     def test_422_unprocessable_entity_bad_data(self, request_data):
         response = self.request(self.default_url, request_data)
         self.assertEqual(response.status_code, 400)
-        self.assertIn('invalid enrollment record', response.data)
 
     @ddt.data(
         [{'status': 'pending'}],
@@ -897,7 +895,6 @@ class ProgramCourseEnrollmentsMixin(EnrollmentsDataMixin):
         request_data.extend(bad_records)
         response = self.request(self.default_url, request_data)
         self.assertEqual(response.status_code, 400)
-        self.assertIn('invalid enrollment record', response.data)
 
     def test_extra_field(self):
         self.prepare_student('learner-1')
@@ -1190,7 +1187,7 @@ class ProgramCourseEnrollmentsModifyMixin(ProgramCourseEnrollmentsMixin):
         self.assert_program_course_enrollment('learner-4', 'active', False)
 
 
-class ProgramCourseEnrollmentPatchTests(ProgramCourseEnrollmentsModifyMixin, APITestCase):
+class ProgramCourseEnrollmentsPatchTests(ProgramCourseEnrollmentsModifyMixin, APITestCase):
     """ Tests for course enrollment PATCH """
 
     def request(self, path, data, **kwargs):
@@ -1230,19 +1227,6 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
     """
     view_name = 'programs_api:v1:program_course_grades'
 
-    @staticmethod
-    def mock_course_grade(percent=75.0, passed=True, letter_grade='B'):
-        return mock.MagicMock(percent=percent, passed=passed, letter_grade=letter_grade)
-
-    @mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
-    def test_204_no_grades_to_return(self, mock_course_grade_factory):
-        mock_course_grade_factory.return_value.iter.return_value = []
-        self.log_in_staff()
-        url = self.get_url(course_id=self.course_id)
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
-        self.assertEqual(response.data['results'], [])
-
     def test_401_if_unauthenticated(self):
         url = self.get_url(course_id=self.course_id)
         response = self.client.get(url)
@@ -1261,20 +1245,32 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
-    @mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
-    def test_200_grades_with_no_exceptions(self, mock_course_grade_factory):
+    def test_204_no_grades_to_return(self):
+        self.log_in_staff()
+        url = self.get_url(course_id=self.course_id)
+        with self.patch_grades_with({}):
+            response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(response.data['results'], [])
+
+    def test_200_grades_with_no_exceptions(self):
         other_student = UserFactory.create(username='other_student')
         self.create_program_and_course_enrollments('student-key', user=self.student)
         self.create_program_and_course_enrollments('other-student-key', user=other_student)
-        mock_course_grades = [
-            (self.student, self.mock_course_grade(), None),
-            (other_student, self.mock_course_grade(percent=40.0, passed=False, letter_grade='F'), None),
-        ]
-        mock_course_grade_factory.return_value.iter.return_value = mock_course_grades
-
+        mock_grades_by_user = {
+            self.student: (
+                self.mock_grade(),
+                None
+            ),
+            other_student: (
+                self.mock_grade(percent=40.0, passed=False, letter_grade='F'),
+                None
+            ),
+        }
         self.log_in_staff()
         url = self.get_url(course_id=self.course_id)
-        response = self.client.get(url)
+        with self.patch_grades_with(mock_grades_by_user):
+            response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         expected_results = [
             {
@@ -1292,20 +1288,21 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
         ]
         self.assertEqual(response.data['results'], expected_results)
 
-    @mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
-    def test_207_grades_with_some_exceptions(self, mock_course_grade_factory):
+    def test_207_grades_with_some_exceptions(self):
         other_student = UserFactory.create(username='other_student')
         self.create_program_and_course_enrollments('student-key', user=self.student)
         self.create_program_and_course_enrollments('other-student-key', user=other_student)
-        mock_course_grades = [
-            (self.student, None, Exception('Bad Data')),
-            (other_student, self.mock_course_grade(percent=40.0, passed=False, letter_grade='F'), None),
-        ]
-        mock_course_grade_factory.return_value.iter.return_value = mock_course_grades
-
+        mock_grades_by_user = {
+            self.student: (None, Exception('Bad Data')),
+            other_student: (
+                self.mock_grade(percent=40.0, passed=False, letter_grade='F'),
+                None,
+            ),
+        }
         self.log_in_staff()
         url = self.get_url(course_id=self.course_id)
-        response = self.client.get(url)
+        with self.patch_grades_with(mock_grades_by_user):
+            response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS)
         expected_results = [
             {
@@ -1321,20 +1318,18 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
         ]
         self.assertEqual(response.data['results'], expected_results)
 
-    @mock.patch(_VIEW_MOCK_FMT.format('CourseGradeFactory'))
-    def test_422_grades_with_only_exceptions(self, mock_course_grade_factory):
+    def test_422_grades_with_only_exceptions(self):
         other_student = UserFactory.create(username='other_student')
         self.create_program_and_course_enrollments('student-key', user=self.student)
         self.create_program_and_course_enrollments('other-student-key', user=other_student)
-        mock_course_grades = [
-            (self.student, None, Exception('Bad Data')),
-            (other_student, None, Exception('Timeout')),
-        ]
-        mock_course_grade_factory.return_value.iter.return_value = mock_course_grades
-
+        mock_grades_by_user = {
+            self.student: (None, Exception('Bad Data')),
+            other_student: (None, Exception('Timeout')),
+        }
         self.log_in_staff()
         url = self.get_url(course_id=self.course_id)
-        response = self.client.get(url)
+        with self.patch_grades_with(mock_grades_by_user):
+            response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
         expected_results = [
             {
@@ -1348,6 +1343,26 @@ class ProgramCourseGradesGetTests(EnrollmentsDataMixin, APITestCase):
         ]
         self.assertEqual(response.data['results'], expected_results)
 
+    @staticmethod
+    def patch_grades_with(grades_by_user):
+        """
+        Create a patcher the CourseGradeFactory to use the `grades_by_user`
+        to determine the grade for each user.
+
+        Arguments:
+            grades_by_user: dict[User: (CourseGrade, Exception)]
+        """
+        def patched_iter(self, users, course_key):  # pylint: disable=unused-argument
+            return [
+                (user, grades_by_user[user][0], grades_by_user[user][1])
+                for user in users
+            ]
+        return mock.patch.object(CourseGradeFactory, 'iter', new=patched_iter)
+
+    @staticmethod
+    def mock_grade(percent=75.0, passed=True, letter_grade='B'):
+        return mock.MagicMock(percent=percent, passed=passed, letter_grade=letter_grade)
+
 
 @ddt.ddt
 class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
@@ -1388,7 +1403,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
         mock_return_value = [program for program in self.mock_program_data if program['type'] == program_type]
 
         with mock.patch(
-            _VIEW_MOCK_FMT.format('get_programs_by_type'),
+            _VIEW_PATCH_FORMAT.format('get_programs_by_type'),
             autospec=True,
             return_value=mock_return_value
         ) as mock_get_programs_by_type:
@@ -1402,7 +1417,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
         self.client.login(username=self.course_staff.username, password=self.password)
 
         with mock.patch(
-            _VIEW_MOCK_FMT.format('get_programs'),
+            _VIEW_PATCH_FORMAT.format('get_programs'),
             autospec=True,
             return_value=[self.mock_program_data[0]]
         ) as mock_get_programs:
@@ -1421,7 +1436,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
         self.client.login(username=self.course_staff.username, password=self.password)
 
         with mock.patch(
-            _VIEW_MOCK_FMT.format('get_programs'),
+            _VIEW_PATCH_FORMAT.format('get_programs'),
             autospec=True,
             side_effect=[[self.mock_program_data[0]], [self.mock_program_data[2]]]
         ) as mock_get_programs:
@@ -1434,7 +1449,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
             mock.call(course=other_course_key),
         ], any_order=True)
 
-    @mock.patch(_VIEW_MOCK_FMT.format('get_programs'), autospec=True, return_value=None)
+    @mock.patch(_VIEW_PATCH_FORMAT.format('get_programs'), autospec=True, return_value=None)
     def test_learner_200_if_no_programs_enrolled(self, mock_get_programs):
         self.client.login(username=self.student.username, password=self.password)
         response = self.client.get(reverse(self.view_name))
@@ -1455,7 +1470,7 @@ class UserProgramReadOnlyAccessGetTests(EnrollmentsDataMixin, APITestCase):
         self.client.login(username=self.student.username, password=self.password)
 
         with mock.patch(
-            _VIEW_MOCK_FMT.format('get_programs'),
+            _VIEW_PATCH_FORMAT.format('get_programs'),
             autospec=True,
             return_value=self.mock_program_data
         ) as mock_get_programs:
@@ -1475,6 +1490,11 @@ class ProgramCourseEnrollmentOverviewGetTests(
     """
     Tests for the ProgramCourseEnrollmentOverview view GET method.
     """
+    patch_resume_url = mock.patch(
+        _VIEW_PATCH_FORMAT.format('get_resume_urls_for_enrollments'),
+        autospec=True,
+    )
+
     @classmethod
     def setUpClass(cls):
         super(ProgramCourseEnrollmentOverviewGetTests, cls).setUpClass()
@@ -1614,16 +1634,14 @@ class ProgramCourseEnrollmentOverviewGetTests(
             expected_course_run_ids.add(text_type(other_course_key))
         self.assertEqual(expected_course_run_ids, actual_course_run_ids)
 
-    _GET_RESUME_URL = _VIEW_MOCK_FMT.format('get_resume_urls_for_enrollments')
-
-    @mock.patch(_GET_RESUME_URL)
+    @patch_resume_url
     def test_blank_resume_url_omitted(self, mock_get_resume_urls):
         self.client.login(username=self.student.username, password=self.password)
         mock_get_resume_urls.return_value = {self.course_id: ''}
         response = self.client.get(self.get_url(self.program_uuid))
         self.assertNotIn('resume_course_run_url', response.data['course_runs'][0])
 
-    @mock.patch(_GET_RESUME_URL)
+    @patch_resume_url
     def test_relative_resume_url_becomes_absolute(self, mock_get_resume_urls):
         self.client.login(username=self.student.username, password=self.password)
         resume_url = '/resume-here'
@@ -1633,7 +1651,7 @@ class ProgramCourseEnrollmentOverviewGetTests(
         self.assertTrue(response_resume_url.startswith("http://testserver"))
         self.assertTrue(response_resume_url.endswith(resume_url))
 
-    @mock.patch(_GET_RESUME_URL)
+    @patch_resume_url
     def test_absolute_resume_url_stays_absolute(self, mock_get_resume_urls):
         self.client.login(username=self.student.username, password=self.password)
         resume_url = 'http://www.resume.com/'
@@ -1968,6 +1986,10 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
     reset_enrollments_cmd = 'reset_enrollment_data'
     reset_users_cmd = 'remove_social_auth_users'
 
+    patch_call_command = mock.patch(
+        _VIEW_PATCH_FORMAT.format('call_command'), autospec=True
+    )
+
     def setUp(self):
         super(EnrollmentDataResetViewTests, self).setUp()
         self.start_cache_isolation()
@@ -1989,14 +2011,14 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
         self.end_cache_isolation()
         super(EnrollmentDataResetViewTests, self).tearDown()
 
-    @mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
+    @patch_call_command
     def test_feature_disabled_by_default(self, mock_call_command):
         response = self.request(self.organization.short_name)
         self.assertEqual(response.status_code, status.HTTP_501_NOT_IMPLEMENTED)
         mock_call_command.assert_has_calls([])
 
     @override_settings(FEATURES=FEATURES_WITH_ENABLED)
-    @mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
+    @patch_call_command
     def test_403_for_non_staff(self, mock_call_command):
         student = UserFactory.create(username='student', password='password')
         self.client.login(username=student.username, password='password')
@@ -2005,7 +2027,7 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
         mock_call_command.assert_has_calls([])
 
     @override_settings(FEATURES=FEATURES_WITH_ENABLED)
-    @mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
+    @patch_call_command
     def test_reset(self, mock_call_command):
         programs = [str(uuid4()), str(uuid4())]
         self.set_org_in_catalog_cache(self.organization, programs)
@@ -2018,7 +2040,7 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
         ])
 
     @override_settings(FEATURES=FEATURES_WITH_ENABLED)
-    @mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
+    @patch_call_command
     def test_reset_without_idp(self, mock_call_command):
         organization = LMSOrganizationFactory()
         programs = [str(uuid4()), str(uuid4())]
@@ -2031,14 +2053,14 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
         ])
 
     @override_settings(FEATURES=FEATURES_WITH_ENABLED)
-    @mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
+    @patch_call_command
     def test_organization_not_found(self, mock_call_command):
         response = self.request('yyz')
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
         mock_call_command.assert_has_calls([])
 
     @override_settings(FEATURES=FEATURES_WITH_ENABLED)
-    @mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
+    @patch_call_command
     def test_no_programs_doesnt_break(self, mock_call_command):
         programs = []
         self.set_org_in_catalog_cache(self.organization, programs)
@@ -2050,7 +2072,7 @@ class EnrollmentDataResetViewTests(ProgramCacheMixin, APITestCase):
         ])
 
     @override_settings(FEATURES=FEATURES_WITH_ENABLED)
-    @mock.patch(_VIEW_MOCK_FMT.format('call_command'), autospec=True)
+    @patch_call_command
     def test_missing_body_content(self, mock_call_command):
         response = self.client.post(
             reverse('programs_api:v1:reset_enrollment_data'),
diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/utils.py b/lms/djangoapps/program_enrollments/rest_api/v1/utils.py
index 91191ecb2b20d0880df285531eb03854040dc18d..a283e6b6f823fb894157b4f754c29bee598c8c40 100644
--- a/lms/djangoapps/program_enrollments/rest_api/v1/utils.py
+++ b/lms/djangoapps/program_enrollments/rest_api/v1/utils.py
@@ -12,7 +12,6 @@ from opaque_keys.edx.keys import CourseKey
 from pytz import UTC
 from rest_framework import status
 
-from bulk_email.api import is_bulk_email_feature_enabled, is_user_opted_out_for_course
 from lms.djangoapps.grades.rest_api.v1.utils import CourseEnrollmentPagination
 from openedx.core.djangoapps.catalog.utils import get_programs, is_course_run_in_program
 from openedx.core.lib.api.view_utils import verify_course_exists
@@ -116,6 +115,28 @@ def verify_course_exists_and_in_program(view_func):
     return wrapped_function
 
 
+def get_enrollment_http_code(result_statuses, ok_statuses):
+    """
+    Given a set of enrollment create/update statuses,
+    return the appropriate HTTP status code.
+
+    Arguments:
+        result_statuses (sequence[str]): set of enrollment operation statuses
+            (for example, 'enrolled', 'not-in-program', etc.)
+        ok_statuses: sequence[str]: set of 'OK' (non-error) statuses
+    """
+    result_status_set = set(result_statuses)
+    ok_status_set = set(ok_statuses)
+    if not result_status_set:
+        return status.HTTP_204_NO_CONTENT
+    if result_status_set.issubset(ok_status_set):
+        return status.HTTP_200_OK
+    elif result_status_set & ok_status_set:
+        return status.HTTP_207_MULTI_STATUS
+    else:
+        return status.HTTP_422_UNPROCESSABLE_ENTITY
+
+
 def get_course_run_status(course_overview, certificate_info):
     """
     Get the progress status of a course run, given the state of a user's
diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/views.py b/lms/djangoapps/program_enrollments/rest_api/v1/views.py
index e58bc87152261fbf181df2e6043ec7cf95307b54..b9d18e9c870972a46863c2afa99903e4a1754506 100644
--- a/lms/djangoapps/program_enrollments/rest_api/v1/views.py
+++ b/lms/djangoapps/program_enrollments/rest_api/v1/views.py
@@ -4,8 +4,6 @@ ProgramEnrollment Views
 """
 from __future__ import absolute_import, unicode_literals
 
-import logging
-
 from ccx_keys.locator import CCXLocator
 from django.conf import settings
 from django.core.exceptions import PermissionDenied
@@ -17,29 +15,30 @@ from edx_rest_framework_extensions.auth.session.authentication import SessionAut
 from opaque_keys.edx.keys import CourseKey
 from organizations.models import Organization
 from rest_framework import status
-from rest_framework.exceptions import ValidationError
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
 from rest_framework.views import APIView
-from six import text_type
 
 from course_modes.models import CourseMode
 from lms.djangoapps.bulk_email.api import get_emails_enabled
 from lms.djangoapps.certificates.api import get_certificate_for_user
 from lms.djangoapps.course_api.api import get_course_run_url, get_due_dates
-from lms.djangoapps.grades.api import CourseGradeFactory, clear_prefetched_course_grades, prefetch_course_grades
 from lms.djangoapps.program_enrollments.api import (
     fetch_program_course_enrollments,
     fetch_program_enrollments,
-    fetch_program_enrollments_by_student
-)
-from lms.djangoapps.program_enrollments.constants import ProgramEnrollmentStatuses
-from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
-from lms.djangoapps.program_enrollments.utils import (
-    ProviderDoesNotExistException,
+    fetch_program_enrollments_by_student,
     get_provider_slug,
-    get_user_by_program_id
+    get_saml_provider_for_organization,
+    iter_program_course_grades,
+    write_program_course_enrollments,
+    write_program_enrollments
 )
+from lms.djangoapps.program_enrollments.constants import (
+    ProgramCourseOperationStatuses,
+    ProgramEnrollmentStatuses,
+    ProgramOperationStatuses
+)
+from lms.djangoapps.program_enrollments.exceptions import ProviderDoesNotExistException
 from openedx.core.djangoapps.catalog.utils import (
     course_run_keys_for_program,
     get_programs,
@@ -55,38 +54,89 @@ from student.models import CourseEnrollment
 from student.roles import CourseInstructorRole, CourseStaffRole, UserBasedRole
 from util.query import read_replica_or_default
 
-from .constants import (
-    ENABLE_ENROLLMENT_RESET_FLAG,
-    MAX_ENROLLMENT_RECORDS,
-    ProgramCourseResponseStatuses,
-    ProgramResponseStatuses
-)
+from .constants import ENABLE_ENROLLMENT_RESET_FLAG, MAX_ENROLLMENT_RECORDS
 from .serializers import (
     CourseRunOverviewListSerializer,
     ProgramCourseEnrollmentRequestSerializer,
     ProgramCourseEnrollmentSerializer,
-    ProgramCourseGradeError,
-    ProgramCourseGradeOk,
     ProgramCourseGradeSerializer,
     ProgramEnrollmentCreateRequestSerializer,
-    ProgramEnrollmentModifyRequestSerializer,
-    ProgramEnrollmentSerializer
+    ProgramEnrollmentSerializer,
+    ProgramEnrollmentUpdateRequestSerializer
 )
 from .utils import (
     ProgramCourseSpecificViewMixin,
     ProgramEnrollmentPagination,
     ProgramSpecificViewMixin,
     get_course_run_status,
+    get_enrollment_http_code,
     verify_course_exists_and_in_program,
     verify_program_exists
 )
 
-logger = logging.getLogger(__name__)
+
+class EnrollmentWriteMixin(object):
+    """
+    Common functionality for viewsets with enrollment-writing POST/PATCH/PUT methods.
+
+    Provides a `handle_write_request` utility method, which depends on the
+    definitions of `serializer_class_by_write_method`, `ok_write_statuses`,
+    and `perform_enrollment_write`.
+    """
+    create_update_by_write_method = {
+        'POST': (True, False),
+        'PATCH': (False, True),
+        'PUT': (True, True),
+    }
+
+    # Set in subclasses
+    serializer_class_by_write_method = "set-me-to-a-dict-with-http-method-keys"
+    ok_write_statuses = "set-me-to-a-set"
+
+    def handle_write_request(self):
+        """
+        Create/modify program enrollments.
+        Returns: Response
+        """
+        serializer_class = self.serializer_class_by_write_method[self.request.method]
+        serializer = serializer_class(data=self.request.data, many=True)
+        serializer.is_valid(raise_exception=True)
+        num_requests = len(self.request.data)
+        if num_requests > MAX_ENROLLMENT_RECORDS:
+            return Response(
+                '{} enrollments requested, but limit is {}.'.format(
+                    MAX_ENROLLMENT_RECORDS, num_requests
+                ),
+                status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
+            )
+        create, update = self.create_update_by_write_method[self.request.method]
+        results = self.perform_enrollment_write(
+            serializer.validated_data, create, update
+        )
+        http_code = get_enrollment_http_code(
+            results.values(), self.ok_write_statuses
+        )
+        return Response(status=http_code, data=results, content_type='application/json')
+
+    def perform_enrollment_write(self, enrollment_requests, create, update):
+        """
+        Perform the write operation. Implemented in subclasses.
+
+        Arguments:
+            enrollment_requests: list[dict]
+            create (bool)
+            update (bool)
+
+        Returns: dict[str: str]
+            Map from external keys to enrollment write statuses.
+        """
+        raise NotImplementedError()
 
 
 class ProgramEnrollmentsView(
+        EnrollmentWriteMixin,
         DeveloperErrorViewMixin,
-        ProgramCourseSpecificViewMixin,
+        ProgramSpecificViewMixin,
         PaginatedAPIView,
 ):
     """
@@ -180,7 +230,7 @@ class ProgramEnrollmentsView(
                 * 'duplicated' - the request body listed the same learner twice
                 * 'conflict' - there is an existing enrollment for that learner, curriculum and program combo
                 * 'invalid-status' - a status other than 'enrolled', 'pending', 'canceled', 'suspended' was entered
-      * 201: CREATED - All students were successfully enrolled.
+      * 200: OK - All students were successfully enrolled.
         * Example json response:
             {
                 '123': 'enrolled',
@@ -244,7 +294,7 @@ class ProgramEnrollmentsView(
                 * 'conflict' - there is an existing enrollment for that learner, curriculum and program combo
                 * 'invalid-status' - a status other than 'enrolled', 'pending', 'canceled', 'suspended' was entered
                 * 'not-in-program' - the user is not in the program and cannot be updated
-      * 201: CREATED - All students were successfully enrolled.
+      * 200: OK - All students were successfully enrolled.
         * Example json response:
             {
                 '123': 'enrolled',
@@ -274,187 +324,65 @@ class ProgramEnrollmentsView(
     permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
     pagination_class = ProgramEnrollmentPagination
 
+    # Overridden from `EnrollmentWriteMixin`
+    serializer_class_by_write_method = {
+        'POST': ProgramEnrollmentCreateRequestSerializer,
+        'PATCH': ProgramEnrollmentUpdateRequestSerializer,
+        'PUT': ProgramEnrollmentCreateRequestSerializer,
+    }
+    ok_write_statuses = ProgramOperationStatuses.__OK__
+
     @verify_program_exists
     def get(self, request, program_uuid=None):
         """ Defines the GET list endpoint for ProgramEnrollment objects. """
         enrollments = fetch_program_enrollments(
-            program_uuid
+            self.program_uuid
         ).using(read_replica_or_default())
         paginated_enrollments = self.paginate_queryset(enrollments)
         serializer = ProgramEnrollmentSerializer(paginated_enrollments, many=True)
         return self.get_paginated_response(serializer.data)
 
     @verify_program_exists
-    def post(self, request, *args, **kwargs):
+    def post(self, request, program_uuid=None):
         """
         Create program enrollments for a list of learners
         """
-        return self.create_or_modify_enrollments(
-            request,
-            kwargs['program_uuid'],
-            ProgramEnrollmentCreateRequestSerializer,
-            self.create_program_enrollment,
-            status.HTTP_201_CREATED,
-        )
+        return self.handle_write_request()
 
     @verify_program_exists
-    def patch(self, request, **kwargs):
+    def patch(self, request, program_uuid=None):  # pylint: disable=unused-argument
         """
-        Modify program enrollments for a list of learners
+        Update program enrollments for a list of learners
         """
-        return self.create_or_modify_enrollments(
-            request,
-            kwargs['program_uuid'],
-            ProgramEnrollmentModifyRequestSerializer,
-            self.modify_program_enrollment,
-            status.HTTP_200_OK,
-        )
+        return self.handle_write_request()
 
     @verify_program_exists
-    def put(self, request, **kwargs):
+    def put(self, request, program_uuid=None):  # pylint: disable=unused-argument
         """
-        Create/modify program enrollments for a list of learners
+        Create/update program enrollments for a list of learners
         """
-        return self.create_or_modify_enrollments(
-            request,
-            kwargs['program_uuid'],
-            ProgramEnrollmentCreateRequestSerializer,
-            self.create_or_modify_program_enrollment,
-            status.HTTP_200_OK,
-        )
-
-    def validate_enrollment_request(self, enrollment, seen_student_keys, serializer_class):
-        """
-        Validates the given enrollment record and checks that it isn't a duplicate
-        """
-        student_key = enrollment['student_key']
-        if student_key in seen_student_keys:
-            return ProgramResponseStatuses.DUPLICATED
-        seen_student_keys.add(student_key)
-        enrollment_serializer = serializer_class(data=enrollment)
-        try:
-            enrollment_serializer.is_valid(raise_exception=True)
-        except ValidationError:
-            if enrollment_serializer.has_invalid_status():
-                return ProgramResponseStatuses.INVALID_STATUS
-            else:
-                raise
-
-    def create_or_modify_enrollments(self, request, program_uuid, serializer_class, operation, success_status):
-        """
-        Process a list of program course enrollment request objects
-        and create or modify enrollments based on method
-        """
-        results = {}
-        seen_student_keys = set()
-        enrollments = []
-
-        if not isinstance(request.data, list):
-            return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
-        if len(request.data) > MAX_ENROLLMENT_RECORDS:
-            return Response(
-                'enrollment limit {}'.format(MAX_ENROLLMENT_RECORDS),
-                status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
-            )
-
-        try:
-            for enrollment_request in request.data:
-                error_status = self.validate_enrollment_request(enrollment_request, seen_student_keys, serializer_class)
-                if error_status:
-                    results[enrollment_request["student_key"]] = error_status
-                else:
-                    enrollments.append(enrollment_request)
-        except KeyError:  # student_key is not in enrollment_request
-            return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
-        except TypeError:  # enrollment_request isn't a dict
-            return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
-        except ValidationError:  # there was some other error raised by the serializer
-            return Response('invalid enrollment record', status.HTTP_422_UNPROCESSABLE_ENTITY)
-
-        program_enrollments = self.get_existing_program_enrollments(program_uuid, enrollments)
-        for enrollment in enrollments:
-            student_key = enrollment["student_key"]
-            if student_key in results and results[student_key] == ProgramResponseStatuses.DUPLICATED:
-                continue
-            try:
-                program_enrollment = program_enrollments[student_key]
-            except KeyError:
-                program_enrollment = None
-            results[student_key] = operation(enrollment, program_uuid, program_enrollment)
-
-        return self._get_created_or_updated_response(results, success_status)
-
-    def create_program_enrollment(self, request_data, program_uuid, program_enrollment):
-        """
-        Create new ProgramEnrollment, unless the learner is already enrolled in the program
-        """
-        if program_enrollment:
-            return ProgramResponseStatuses.CONFLICT
+        return self.handle_write_request()
 
-        student_key = request_data.get('student_key')
-        try:
-            user = get_user_by_program_id(student_key, program_uuid)
-        except ProviderDoesNotExistException:
-            # IDP has not yet been set up, just create waiting enrollments
-            user = None
-
-        enrollment = ProgramEnrollment.objects.create(
-            user=user,
-            external_user_key=student_key,
-            program_uuid=program_uuid,
-            curriculum_uuid=request_data.get('curriculum_uuid'),
-            status=request_data.get('status')
-        )
-        return enrollment.status
-
-    # pylint: disable=unused-argument
-    def modify_program_enrollment(self, request_data, program_uuid, program_enrollment):
-        """
-        Change the status of an existing program enrollment
+    def perform_enrollment_write(self, enrollment_requests, create, update):
         """
-        if not program_enrollment:
-            return ProgramResponseStatuses.NOT_IN_PROGRAM
+        Perform the program enrollment write operation.
+        Overridden from `EnrollmentWriteMixin`.
 
-        program_enrollment.status = request_data.get('status')
-        program_enrollment.save()
-        return program_enrollment.status
-
-    def create_or_modify_program_enrollment(self, request_data, program_uuid, program_enrollment):
-        if program_enrollment:
-            return self.modify_program_enrollment(request_data, program_uuid, program_enrollment)
-        else:
-            return self.create_program_enrollment(request_data, program_uuid, program_enrollment)
-
-    def get_existing_program_enrollments(self, program_uuid, student_data):
-        """ Returns the existing program enrollments for the given students and program """
-        student_keys = [data['student_key'] for data in student_data]
-        program_enrollments_qs = fetch_program_enrollments(
-            program_uuid=program_uuid, external_user_keys=student_keys
-        )
-        return {e.external_user_key: e for e in program_enrollments_qs}
+        Arguments:
+            enrollment_requests: list[dict]
+            create (bool)
+            update (bool)
 
-    def _get_created_or_updated_response(self, response_data, default_status=status.HTTP_201_CREATED):
+        Returns: dict[str: str]
+            Map from external keys to enrollment write statuses.
         """
-        Helper method to determine an appropirate HTTP response status code.
-        """
-        response_status = default_status
-        good_count = len([
-            v for v in response_data.values()
-            if v in ProgramResponseStatuses.__OK__
-        ])
-        if not good_count:
-            response_status = status.HTTP_422_UNPROCESSABLE_ENTITY
-        elif good_count != len(response_data):
-            response_status = status.HTTP_207_MULTI_STATUS
-
-        return Response(
-            status=response_status,
-            data=response_data,
-            content_type='application/json',
+        return write_program_enrollments(
+            self.program_uuid, enrollment_requests, create=create, update=update
         )
 
 
 class ProgramCourseEnrollmentsView(
+        EnrollmentWriteMixin,
         DeveloperErrorViewMixin,
         ProgramCourseSpecificViewMixin,
         PaginatedAPIView,
@@ -540,6 +468,14 @@ class ProgramCourseEnrollmentsView(
     permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
     pagination_class = ProgramEnrollmentPagination
 
+    # Overridden from `EnrollmentWriteMixin`
+    serializer_class_by_write_method = {
+        'POST': ProgramCourseEnrollmentRequestSerializer,
+        'PATCH': ProgramCourseEnrollmentRequestSerializer,
+        'PUT': ProgramCourseEnrollmentRequestSerializer,
+    }
+    ok_write_statuses = ProgramCourseOperationStatuses.__OK__
+
     @verify_course_exists_and_in_program
     def get(self, request, program_uuid=None, course_id=None):
         """
@@ -559,11 +495,7 @@ class ProgramCourseEnrollmentsView(
         """
         Enroll a list of students in a course in a program
         """
-        return self.create_or_modify_enrollments(
-            request,
-            program_uuid,
-            self.enroll_learner_in_course
-        )
+        return self.handle_write_request()
 
     @verify_course_exists_and_in_program
     # pylint: disable=unused-argument
@@ -571,11 +503,7 @@ class ProgramCourseEnrollmentsView(
         """
         Modify the program course enrollments of a list of learners
         """
-        return self.create_or_modify_enrollments(
-            request,
-            program_uuid,
-            self.modify_learner_enrollment_status
-        )
+        return self.handle_write_request()
 
     @verify_course_exists_and_in_program
     # pylint: disable=unused-argument
@@ -583,140 +511,29 @@ class ProgramCourseEnrollmentsView(
         """
         Create or Update the program course enrollments of a list of learners
         """
-        return self.create_or_modify_enrollments(
-            request,
-            program_uuid,
-            self.create_or_update_learner_enrollment
-        )
+        return self.handle_write_request()
 
-    def create_or_modify_enrollments(self, request, program_uuid, operation):
+    def perform_enrollment_write(self, enrollment_requests, create, update):
         """
-        Process a list of program course enrollment request objects
-        and create or modify enrollments based on method
-        """
-        results = {}
-        seen_student_keys = set()
-        enrollments = []
+        Perform the program enrollment write operation.
+        Overridden from `EnrollmentWriteMixin`.
 
-        if not isinstance(request.data, list):
-            return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
-        if len(request.data) > MAX_ENROLLMENT_RECORDS:
-            return Response(
-                'enrollment limit 25', status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
-            )
-
-        try:
-            for enrollment_request in request.data:
-                error_status = self.check_enrollment_request(enrollment_request, seen_student_keys)
-                if error_status:
-                    results[enrollment_request["student_key"]] = error_status
-                else:
-                    enrollments.append(enrollment_request)
-        except KeyError:  # student_key is not in enrollment_request
-            return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
-        except TypeError:  # enrollment_request isn't a dict
-            return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
-        except ValidationError:  # there was some other error raised by the serializer
-            return Response('invalid enrollment record', status.HTTP_400_BAD_REQUEST)
-
-        program_enrollments = self.get_existing_program_enrollments(program_uuid, enrollments)
-        for enrollment in enrollments:
-            student_key = enrollment["student_key"]
-            if student_key in results and results[student_key] == ProgramCourseResponseStatuses.DUPLICATED:
-                continue
-            try:
-                program_enrollment = program_enrollments[student_key]
-            except KeyError:
-                results[student_key] = ProgramCourseResponseStatuses.NOT_IN_PROGRAM
-            else:
-                program_course_enrollment = program_enrollment.get_program_course_enrollment(self.course_key)
-                results[student_key] = operation(enrollment, program_enrollment, program_course_enrollment)
-
-        good_count = sum(
-            1 for _, v in results.items()
-            if v in ProgramCourseResponseStatuses.__OK__
-        )
-        if not good_count:
-            return Response(results, status.HTTP_422_UNPROCESSABLE_ENTITY)
-        if good_count != len(results):
-            return Response(results, status.HTTP_207_MULTI_STATUS)
-        else:
-            return Response(results)
-
-    def check_enrollment_request(self, enrollment, seen_student_keys):
-        """
-        Checks that the given enrollment record is valid and hasn't been duplicated
-        """
-        student_key = enrollment['student_key']
-        if student_key in seen_student_keys:
-            return ProgramCourseResponseStatuses.DUPLICATED
-        seen_student_keys.add(student_key)
-        enrollment_serializer = ProgramCourseEnrollmentRequestSerializer(data=enrollment)
-        try:
-            enrollment_serializer.is_valid(raise_exception=True)
-        except ValidationError as e:
-            if enrollment_serializer.has_invalid_status():
-                return ProgramCourseResponseStatuses.INVALID_STATUS
-            else:
-                raise e
-
-    def get_existing_program_enrollments(self, program_uuid, enrollments):
-        """
-        Parameters:
-            - enrollments: A list of enrollment requests
-        Returns:
-            - Dictionary mapping all student keys in the enrollment requests
-              to that user's existing program enrollment in <self.program>
-        """
-        external_user_keys = [e["student_key"] for e in enrollments]
-        existing_enrollments = fetch_program_enrollments(
-            program_uuid=program_uuid,
-            external_user_keys=external_user_keys,
-        ).prefetch_related('program_course_enrollments')
-        return {enrollment.external_user_key: enrollment for enrollment in existing_enrollments}
-
-    def enroll_learner_in_course(self, enrollment_request, program_enrollment, program_course_enrollment):
-        """
-        Attempts to enroll the specified user into the course as a part of the
-         given program enrollment with the given status
+        Arguments:
+            enrollment_requests: list[dict]
+            create (bool)
+            update (bool)
 
-        Returns the actual status
+        Returns: dict[str: str]
+            Map from external keys to enrollment write statuses.
         """
-        if program_course_enrollment:
-            return ProgramCourseResponseStatuses.CONFLICT
-
-        return ProgramCourseEnrollment.create_program_course_enrollment(
-            program_enrollment,
+        return write_program_course_enrollments(
+            self.program_uuid,
             self.course_key,
-            enrollment_request['status']
+            enrollment_requests,
+            create=create,
+            update=update,
         )
 
-    # pylint: disable=unused-argument
-    def modify_learner_enrollment_status(self, enrollment_request, program_enrollment, program_course_enrollment):
-        """
-        Attempts to modify the specified user's enrollment in the given course
-        in the given program
-        """
-        if program_course_enrollment is None:
-            return ProgramCourseResponseStatuses.NOT_FOUND
-        return program_course_enrollment.change_status(enrollment_request['status'])
-
-    def create_or_update_learner_enrollment(self, enrollment_request, program_enrollment, program_course_enrollment):
-        """
-        Attempts to create or update the specified user's enrollment in the given course
-        in the given program
-        """
-        if program_course_enrollment is None:
-            # create the course enrollment
-            return ProgramCourseEnrollment.create_program_course_enrollment(
-                program_enrollment,
-                self.course_key,
-                enrollment_request['status']
-            )
-        else:
-            # Update course enrollment
-            return program_course_enrollment.change_status(enrollment_request['status'])
-
 
 class ProgramCourseGradesView(
         DeveloperErrorViewMixin,
@@ -798,83 +615,13 @@ class ProgramCourseGradesView(
         """
         Defines the GET list endpoint for ProgramCourseGrade objects.
         """
-        course_key = CourseKey.from_string(course_id)
-        grade_results = self._load_grade_results(program_uuid, course_key)
+        grade_results = list(iter_program_course_grades(
+            self.program_uuid, self.course_key, self.paginate_queryset
+        ))
         serializer = ProgramCourseGradeSerializer(grade_results, many=True)
         response_code = self._calc_response_code(grade_results)
         return self.get_paginated_response(serializer.data, status_code=response_code)
 
-    def _load_grade_results(self, program_uuid, course_key):
-        """
-        Load grades (or grading errors) for a given program courserun.
-
-        Arguments:
-            program_uuid (str)
-            course_key (CourseKey)
-
-        Returns: list[BaseProgramCourseGrade]
-        """
-        enrollments_qs = fetch_program_course_enrollments(
-            program_uuid=program_uuid,
-            course_key=course_key,
-            realized_only=True,
-        ).select_related(
-            'program_enrollment',
-            'program_enrollment__user',
-        ).using(read_replica_or_default())
-        paginated_enrollments = self.paginate_queryset(enrollments_qs)
-        if not paginated_enrollments:
-            return []
-
-        # Hint: `zip(*(list))` can be read as "unzip(list)"
-        enrollments, users = zip(*(
-            (enrollment, enrollment.program_enrollment.user)
-            for enrollment in paginated_enrollments
-        ))
-        enrollment_grade_pairs = zip(
-            enrollments, self._iter_grades(course_key, list(users))
-        )
-        grade_results = [
-            (
-                ProgramCourseGradeOk(enrollment, grade)
-                if grade
-                else ProgramCourseGradeError(enrollment, exception)
-            )
-            for enrollment, (grade, exception) in enrollment_grade_pairs
-        ]
-        return grade_results
-
-    @staticmethod
-    def _iter_grades(course_key, users):
-        """
-        Load a user grades for a course, using bulk fetching for efficiency.
-
-        Arguments:
-            course_key (CourseKey)
-            users (list[User])
-
-        Returns: iterable[( CourseGradeBase|NoneType, Exception|NoneType )]
-            Iterable of pairs, in same order as `users`.
-            The first item in the pair is the grade, or None if loading the
-                grade failed.
-            The second item in the pair is an exception or None.
-        """
-        prefetch_course_grades(course_key, users)
-        try:
-            grades_iter = CourseGradeFactory().iter(users, course_key=course_key)
-            for user, course_grade, exception in grades_iter:
-                if not course_grade:
-                    fmt = 'Failed to load course grade for user ID {} in {}: {}'
-                    err_str = fmt.format(
-                        user.id,
-                        course_key,
-                        text_type(exception) if exception else 'Unknown error'
-                    )
-                    logger.error(err_str)
-                yield course_grade, exception
-        finally:
-            clear_prefetched_course_grades(course_key)
-
     @staticmethod
     def _calc_response_code(grade_results):
         """
@@ -1250,10 +997,12 @@ class EnrollmentDataResetView(APIView):
             return Response('organization {} not found'.format(org_key), status.HTTP_404_NOT_FOUND)
 
         try:
-            idp_slug = get_provider_slug(organization)
-            call_command('remove_social_auth_users', idp_slug, force=True)
+            provider = get_saml_provider_for_organization(organization)
         except ProviderDoesNotExistException:
             pass
+        else:
+            idp_slug = get_provider_slug(provider)
+            call_command('remove_social_auth_users', idp_slug, force=True)
 
         programs = get_programs_for_organization(organization=organization.short_name)
         if programs:
diff --git a/lms/djangoapps/program_enrollments/signals.py b/lms/djangoapps/program_enrollments/signals.py
index 0fe1a945da702f072d777cac5788a07dcf93042c..bb5299984f99b3dac5d59b7e9443f53dc2f72d9f 100644
--- a/lms/djangoapps/program_enrollments/signals.py
+++ b/lms/djangoapps/program_enrollments/signals.py
@@ -11,10 +11,9 @@ from social_django.models import UserSocialAuth
 
 from openedx.core.djangoapps.catalog.utils import get_programs
 from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_MISC
-from student.models import CourseEnrollmentException
 from third_party_auth.models import SAMLProviderConfig
 
-from .api import fetch_program_enrollments_by_student
+from .api import fetch_program_enrollments_by_student, link_program_enrollment_to_lms_user
 from .models import ProgramEnrollment
 
 logger = logging.getLogger(__name__)
@@ -95,17 +94,4 @@ def matriculate_learner(user, uid):
                 authorizing_org.short_name
             )
             continue
-
-        enrollment.user = user
-        enrollment.save()
-        for program_course_enrollment in enrollment.program_course_enrollments.all():
-            try:
-                program_course_enrollment.enroll(user)
-            except CourseEnrollmentException as e:
-                logger.warning(
-                    'Failed to enroll user=%s with waiting program_course_enrollment=%s: %s',
-                    user.id,
-                    program_course_enrollment.id,
-                    e,
-                )
-                raise e
+        link_program_enrollment_to_lms_user(enrollment, user)
diff --git a/lms/djangoapps/program_enrollments/tests/test_models.py b/lms/djangoapps/program_enrollments/tests/test_models.py
index cab82f9ad0815827cadbe8067814b0d3f6826391..596991bf39614cbbee854d04121b861af5895038 100644
--- a/lms/djangoapps/program_enrollments/tests/test_models.py
+++ b/lms/djangoapps/program_enrollments/tests/test_models.py
@@ -6,18 +6,15 @@ from __future__ import absolute_import, unicode_literals
 from uuid import uuid4
 
 import ddt
-import mock
 from django.db.utils import IntegrityError
 from django.test import TestCase
 from edx_django_utils.cache import RequestCache
 from opaque_keys.edx.keys import CourseKey
-from testfixtures import LogCapture
 
 from course_modes.models import CourseMode
 from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
 from openedx.core.djangoapps.catalog.tests.factories import generate_course_run_key
 from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
-from student.models import CourseEnrollment, NonExistentCourseError
 from student.tests.factories import CourseEnrollmentFactory, UserFactory
 
 
@@ -173,89 +170,3 @@ class ProgramCourseEnrollmentModelTests(TestCase):
             course_enrollment=None,
             status="active"
         )
-
-    def test_change_status_no_enrollment(self):
-        program_course_enrollment = self._create_completed_program_course_enrollment()
-        with LogCapture() as capture:
-            program_course_enrollment.course_enrollment = None
-            program_course_enrollment.change_status("inactive")
-            expected_message = "User {} {} {} has no course_enrollment".format(
-                self.user,
-                self.program_enrollment,
-                self.course_key
-            )
-            capture.check(
-                ('lms.djangoapps.program_enrollments.models', 'WARNING', expected_message)
-            )
-
-    def test_change_status_not_active_or_inactive(self):
-        program_course_enrollment = self._create_completed_program_course_enrollment()
-        with LogCapture() as capture:
-            status = "potential-future-status-0123"
-            program_course_enrollment.change_status(status)
-            message = ("Changed {} status to {}, not changing course_enrollment"
-                       " status because status is not 'active' or 'inactive'")
-            expected_message = message.format(program_course_enrollment, status)
-            capture.check(
-                ('lms.djangoapps.program_enrollments.models', 'WARNING', expected_message)
-            )
-
-    def test_enroll_new_course_enrollment(self):
-        program_course_enrollment = self._create_waiting_program_course_enrollment()
-        program_course_enrollment.enroll(self.user)
-
-        course_enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key)
-        self.assertEqual(course_enrollment.user, self.user)
-        self.assertEqual(course_enrollment.course.id, self.course_key)
-        self.assertEqual(course_enrollment.mode, CourseMode.MASTERS)
-
-    def test_enrollment_course_not_found(self):
-        nonexistent_key = 'course-v1:edX+Overview+DNE'
-        program_course_enrollment = ProgramCourseEnrollment.objects.create(
-            program_enrollment=self.program_enrollment,
-            course_key=nonexistent_key,
-            course_enrollment=None,
-            status="active"
-        )
-
-        with LogCapture() as capture:
-            with self.assertRaises(NonExistentCourseError):
-                program_course_enrollment.enroll(self.user)
-            expected = "User {} failed to enroll in non-existent course {}".format(
-                self.user.id, nonexistent_key
-            )
-            capture.check(
-                ('lms.djangoapps.program_enrollments.models', 'WARNING', expected)
-            )
-
-    @ddt.data(
-        (CourseMode.VERIFIED, CourseMode.VERIFIED),
-        (CourseMode.AUDIT, CourseMode.MASTERS),
-        (CourseMode.HONOR, CourseMode.MASTERS)
-    )
-    @ddt.unpack
-    def test_enroll_existing_course_enrollment(self, original_mode, result_mode):
-        course_enrollment = CourseEnrollmentFactory.create(
-            course_id=self.course_key,
-            user=self.user,
-            mode=original_mode
-        )
-        program_course_enrollment = self._create_waiting_program_course_enrollment()
-
-        program_course_enrollment.enroll(self.user)
-
-        course_enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key)
-        self.assertEqual(course_enrollment.user, self.user)
-        self.assertEqual(course_enrollment.course.id, self.course_key)
-        self.assertEqual(course_enrollment.mode, result_mode)
-
-    @mock.patch('student.models.CourseEnrollment.is_enrollment_closed', return_value=True)
-    def test_closed_enrollments_ignored(self, _mock):
-        """ enrolling through program enrollments should ignore checks on enrollment """
-        program_course_enrollment = self._create_waiting_program_course_enrollment()
-        program_course_enrollment.enroll(self.user)
-
-        course_enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key)
-        self.assertEqual(course_enrollment.user, self.user)
-        self.assertEqual(course_enrollment.course.id, self.course_key)
-        self.assertEqual(course_enrollment.mode, CourseMode.MASTERS)
diff --git a/lms/djangoapps/program_enrollments/tests/test_signals.py b/lms/djangoapps/program_enrollments/tests/test_signals.py
index c42fbe77f115704d798b0e3a279cd6c32436eacd..f4bf0799afa0617e6b14d86ebfd19dd0b69b97d3 100644
--- a/lms/djangoapps/program_enrollments/tests/test_signals.py
+++ b/lms/djangoapps/program_enrollments/tests/test_signals.py
@@ -341,27 +341,16 @@ class SocialAuthEnrollmentCompletionSignalTest(CacheIsolationTestCase):
                 )
             )
 
-    def test_log_on_enrollment_failure(self):
+    def test_exception_on_enrollment_failure(self):
         program_enrollment = self._create_waiting_program_enrollment()
-        program_course_enrollments = self._create_waiting_course_enrollments(program_enrollment)
+        self._create_waiting_course_enrollments(program_enrollment)
 
         with mock.patch('student.models.CourseEnrollment.enroll') as enrollMock:
             enrollMock.side_effect = CourseEnrollmentException('something has gone wrong')
-            with LogCapture(logger.name) as log:
-                with pytest.raises(CourseEnrollmentException):
-                    UserSocialAuth.objects.create(
-                        user=self.user,
-                        uid='{0}:{1}'.format(self.provider_slug, self.external_id)
-                    )
-                error_template = 'Failed to enroll user={} with waiting program_course_enrollment={}: {}'
-                log.check_present(
-                    (
-                        logger.name,
-                        'WARNING',
-                        error_template.format(
-                            self.user.id, program_course_enrollments[0].id, 'something has gone wrong'
-                        )
-                    )
+            with pytest.raises(CourseEnrollmentException):
+                UserSocialAuth.objects.create(
+                    user=self.user,
+                    uid='{0}:{1}'.format(self.provider_slug, self.external_id)
                 )
 
     def test_log_on_unexpected_exception(self):
@@ -371,7 +360,7 @@ class SocialAuthEnrollmentCompletionSignalTest(CacheIsolationTestCase):
         program_enrollment = self._create_waiting_program_enrollment()
         self._create_waiting_course_enrollments(program_enrollment)
 
-        with mock.patch('lms.djangoapps.program_enrollments.models.ProgramCourseEnrollment.enroll') as enrollMock:
+        with mock.patch('lms.djangoapps.program_enrollments.api.linking.enroll_in_masters_track') as enrollMock:
             enrollMock.side_effect = Exception('unexpected error')
             with LogCapture(logger.name) as log:
                 with self.assertRaisesRegex(Exception, 'unexpected error'):
diff --git a/lms/djangoapps/program_enrollments/tests/test_utils.py b/lms/djangoapps/program_enrollments/tests/test_utils.py
deleted file mode 100644
index c067acd6ff8ebf1b46c03140c45567560645c29c..0000000000000000000000000000000000000000
--- a/lms/djangoapps/program_enrollments/tests/test_utils.py
+++ /dev/null
@@ -1,157 +0,0 @@
-"""
-Unit tests for program_enrollments utils.
-"""
-from __future__ import absolute_import, unicode_literals
-
-from uuid import uuid4
-
-import ddt
-import pytest
-from django.core.cache import cache
-from organizations.tests.factories import OrganizationFactory
-from social_django.models import UserSocialAuth
-
-from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL
-from openedx.core.djangoapps.catalog.tests.factories import OrganizationFactory as CatalogOrganizationFactory
-from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
-from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
-from program_enrollments.utils import (
-    OrganizationDoesNotExistException,
-    ProgramDoesNotExistException,
-    ProviderConfigurationException,
-    ProviderDoesNotExistException,
-    get_user_by_program_id
-)
-from student.tests.factories import UserFactory
-from third_party_auth.tests.factories import SAMLProviderConfigFactory
-
-
-@ddt.ddt
-class GetPlatformUserTests(CacheIsolationTestCase):
-    """
-    Tests for the get_platform_user function
-    """
-    ENABLED_CACHES = ['default']
-
-    def setUp(self):
-        super(GetPlatformUserTests, self).setUp()
-        self.program_uuid = uuid4()
-        self.organization_key = 'ufo'
-        self.external_user_id = '1234'
-        self.user = UserFactory.create()
-        self.setup_catalog_cache(self.program_uuid, self.organization_key)
-
-    def setup_catalog_cache(self, program_uuid, organization_key):
-        """
-        helper function to initialize a cached program with an single authoring_organization
-        """
-        catalog_org = CatalogOrganizationFactory.create(key=organization_key)
-        program = ProgramFactory.create(
-            uuid=program_uuid,
-            authoring_organizations=[catalog_org]
-        )
-        cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=program_uuid), program, None)
-
-    def create_social_auth_entry(self, user, provider, external_id):
-        """
-        helper functio to create a user social auth entry
-        """
-        UserSocialAuth.objects.create(
-            user=user,
-            uid='{0}:{1}'.format(provider.slug, external_id)
-        )
-
-    def test_get_user_success(self):
-        """
-        Test lms user is successfully found
-        """
-        organization = OrganizationFactory.create(short_name=self.organization_key)
-        provider = SAMLProviderConfigFactory.create(organization=organization)
-        self.create_social_auth_entry(self.user, provider, self.external_user_id)
-
-        user = get_user_by_program_id(self.external_user_id, self.program_uuid)
-        self.assertEquals(user, self.user)
-
-    def test_social_auth_user_not_created(self):
-        """
-        None should be returned if no lms user exists for an external id
-        """
-        organization = OrganizationFactory.create(short_name=self.organization_key)
-        SAMLProviderConfigFactory.create(organization=organization)
-
-        user = get_user_by_program_id(self.external_user_id, self.program_uuid)
-        self.assertIsNone(user)
-
-    def test_catalog_program_does_not_exist(self):
-        """
-        Test ProgramDoesNotExistException is thrown if the program cache does
-        not include the requested program uuid.
-        """
-        with pytest.raises(ProgramDoesNotExistException):
-            get_user_by_program_id('school-id-1234', uuid4())
-
-    def test_catalog_program_missing_org(self):
-        """
-        Test OrganizationDoesNotExistException is thrown if the cached program does not
-        have an authoring organization.
-        """
-        program = ProgramFactory.create(
-            uuid=self.program_uuid,
-            authoring_organizations=[]
-        )
-        cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=self.program_uuid), program, None)
-
-        organization = OrganizationFactory.create(short_name=self.organization_key)
-        provider = SAMLProviderConfigFactory.create(organization=organization)
-        self.create_social_auth_entry(self.user, provider, self.external_user_id)
-
-        with pytest.raises(OrganizationDoesNotExistException):
-            get_user_by_program_id(self.external_user_id, self.program_uuid)
-
-    def test_lms_organization_not_found(self):
-        """
-        Test an OrganizationDoesNotExistException is thrown if the LMS has no organization
-        matching the catalog program's authoring_organization
-        """
-        organization = OrganizationFactory.create(short_name='some_other_org')
-        provider = SAMLProviderConfigFactory.create(organization=organization)
-        self.create_social_auth_entry(self.user, provider, self.external_user_id)
-
-        with pytest.raises(OrganizationDoesNotExistException):
-            get_user_by_program_id(self.external_user_id, self.program_uuid)
-
-    def test_saml_provider_not_found(self):
-        """
-        Test an exception is thrown if no SAML provider exists for this program's organization
-        """
-        OrganizationFactory.create(short_name=self.organization_key)
-
-        with pytest.raises(ProviderDoesNotExistException):
-            get_user_by_program_id(self.external_user_id, self.program_uuid)
-
-    @ddt.data(True, False)
-    def test_multiple_saml_providers(self, second_config_enabled):
-        """
-        If multiple samlprovider records exist with the same organization
-        an exception is raised
-        """
-        organization = OrganizationFactory.create(short_name=self.organization_key)
-        provider = SAMLProviderConfigFactory.create(organization=organization)
-
-        self.create_social_auth_entry(self.user, provider, self.external_user_id)
-
-        # create a second active config for the same organization
-        SAMLProviderConfigFactory.create(
-            organization=organization, slug='foox', enabled=second_config_enabled
-        )
-
-        try:
-            get_user_by_program_id(self.external_user_id, self.program_uuid)
-        except ProviderConfigurationException:
-            self.assertTrue(
-                second_config_enabled, 'Unexpected error when second config is disabled'
-            )
-        else:
-            self.assertFalse(
-                second_config_enabled, 'Expected error was not raised when second config is enabled'
-            )
diff --git a/lms/djangoapps/program_enrollments/utils.py b/lms/djangoapps/program_enrollments/utils.py
deleted file mode 100644
index 85249bcd4dc4d1cb0267bda6c24ee3d363875ede..0000000000000000000000000000000000000000
--- a/lms/djangoapps/program_enrollments/utils.py
+++ /dev/null
@@ -1,117 +0,0 @@
-"""
-utility functions for program enrollments
-"""
-from __future__ import absolute_import, unicode_literals
-
-import logging
-
-from organizations.models import Organization
-from social_django.models import UserSocialAuth
-
-from openedx.core.djangoapps.catalog.utils import get_programs
-from third_party_auth.models import SAMLProviderConfig
-
-log = logging.getLogger(__name__)
-
-
-class ProgramDoesNotExistException(Exception):
-    pass
-
-
-class OrganizationDoesNotExistException(Exception):
-    pass
-
-
-class ProviderDoesNotExistException(Exception):
-    pass
-
-
-class ProviderConfigurationException(Exception):
-    pass
-
-
-def get_user_by_program_id(external_user_id, program_uuid):
-    """
-    Returns a User model for an external_user_id with a social auth entry.
-
-    Args:
-        external_user_id: external user id used for social auth
-        program_uuid: a program this user is/will be enrolled in
-
-    Returns:
-        A User object or None, if no user with the given external id for the given organization
-        exists.
-
-    Raises:
-        ProgramDoesNotExistException if no such program exists.
-        OrganizationDoesNotExistException if no organization exists.
-        ProviderDoesNotExistException if there is no SAML provider configured for the related
-        organization.
-    """
-    program = get_programs(uuid=program_uuid)
-    if program is None:
-        log.error(u'Unable to find catalog program matching uuid [%s]', program_uuid)
-        raise ProgramDoesNotExistException
-
-    try:
-        org_key = program['authoring_organizations'][0]['key']
-        organization = Organization.objects.get(short_name=org_key)
-    except (KeyError, IndexError):
-        log.error(
-            u'Cannot determine authoring organization key for catalog program [%s]', program_uuid
-        )
-        raise OrganizationDoesNotExistException
-    except Organization.DoesNotExist:
-        log.error(u'Unable to find organization for short_name [%s]', org_key)
-        raise OrganizationDoesNotExistException
-
-    return get_user_by_organization(external_user_id, organization)
-
-
-def get_user_by_organization(external_user_id, organization):
-    """
-    Returns a User model for an external_user_id with a social auth entry.
-
-    This function finds a matching SAML Provider for the given organization, and looks
-    for a social auth entry with the provided exernal id.
-
-    Args:
-        external_user_id: external user id used for social auth
-        organization: organization providing saml authentication for this user
-
-    Returns:
-        A User object or None, if no user with the given external id for the given organization
-        exists.
-
-    Raises:
-        ProviderDoesNotExistException if there is no SAML provider configured for the related
-        organization.
-    """
-    provider_slug = get_provider_slug(organization)
-    try:
-        social_auth_uid = '{0}:{1}'.format(provider_slug, external_user_id)
-        return UserSocialAuth.objects.get(uid=social_auth_uid).user
-    except UserSocialAuth.DoesNotExist:
-        return None
-
-
-def get_provider_slug(organization):
-    """
-    Returns slug for the currently configured saml provder on an Organization
-
-    Raises:
-        ProviderDoesNotExistsException
-        ProviderConfigurationException
-    """
-    try:
-        provider_config = organization.samlproviderconfig_set.current_set().get(enabled=True)
-    except SAMLProviderConfig.DoesNotExist:
-        log.error('No SAML provider found for organization id [%s]', organization.id)
-        raise ProviderDoesNotExistException
-    except SAMLProviderConfig.MultipleObjectsReturned:
-        log.error(
-            'Multiple active SAML configurations found for organization=%s. Expected one.',
-            organization.short_name,
-        )
-        raise ProviderConfigurationException
-    return provider_config.provider_id.strip('saml-')
diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py
index f0194d25c7535fe9b2c5e07d298636006710d3ba..f2416a7508a2d42088cb0f9d37fb555529a5452a 100644
--- a/lms/djangoapps/support/tests/test_views.py
+++ b/lms/djangoapps/support/tests/test_views.py
@@ -9,12 +9,13 @@ import itertools
 import json
 import re
 from datetime import datetime, timedelta
-from uuid import uuid4
+from uuid import uuid4, UUID
 
 import ddt
 import six
 from django.contrib.auth.models import User
 from django.db.models import signals
+from django.http import HttpResponse
 from django.urls import reverse
 from mock import patch
 from pytz import UTC
@@ -22,7 +23,6 @@ from pytz import UTC
 from common.test.utils import disable_signal
 from course_modes.models import CourseMode
 from course_modes.tests.factories import CourseModeFactory
-from lms.djangoapps.program_enrollments.api import NO_PROGRAM_ENROLLMENT_TEMPLATE
 from lms.djangoapps.verify_student.models import VerificationDeadline
 from student.models import ENROLLED_TO_ENROLLED, CourseEnrollment, CourseEnrollmentAttribute, ManualEnrollmentAudit
 from student.roles import GlobalStaff, SupportStaffRole
@@ -446,6 +446,12 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase):
     """
     Tests for the link_program_enrollments support view.
     """
+    patch_render = patch(
+        'support.views.program_enrollments.render_to_response',
+        return_value=HttpResponse(),
+        autospec=True,
+    )
+
     def setUp(self):
         """Make the user support staff. """
         super(SupportViewLinkProgramEnrollmentsTests, self).setUp()
@@ -454,53 +460,62 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase):
         self.program_uuid = str(uuid4())
         self.text = '0001,user-0001\n0002,user-02'
 
-    def _assert_props(self, field_name, value, response):
-        self.assertIn('"{}": "{}"'.format(field_name, value), six.text_type(response.content, encoding='utf-8'))
-
-    def _assert_props_list(self, field_name, values, response):
+    @patch_render
+    def test_get(self, mocked_render):
+        self.client.get(self.url)
+        render_call_dict = mocked_render.call_args[0][1]
+        assert render_call_dict == {
+            'successes': [],
+            'errors': [],
+            'program_uuid': '',
+            'text': ''
+        }
+
+    def test_rendering(self):
         """
-        Assert that that page is being rendered with a specific list of props
+        Test the view without mocking out the rendering like the rest of the tests.
         """
-        values_str = ''
-        if values:
-            values_str = '", "'.join(values)
-            values_str = '"{}"'.format(values_str)
-        self.assertIn(u'"{}": [{}]'.format(field_name, values_str), six.text_type(response.content, encoding='utf-8'))
-
-    def test_get(self):
         response = self.client.get(self.url)
-        self._assert_props_list('successes', [], response)
-        self._assert_props_list('errors', [], response)
-        self._assert_props('programUUID', '', response)
-        self._assert_props('text', '', response)
+        content = six.text_type(response.content, encoding='utf-8')
+        assert '"programUUID": ""' in content
+        assert '"text": ""' in content
 
-    def test_invalid_uuid(self):
-        response = self.client.post(self.url, data={
+    @patch_render
+    def test_invalid_uuid(self, mocked_render):
+        self.client.post(self.url, data={
             'program_uuid': 'notauuid',
             'text': self.text,
         })
-        self._assert_props_list('errors', [u'badly formed hexadecimal UUID string'], response)
+        msg = u"Supplied program UUID 'notauuid' is not a valid UUID."
+        render_call_dict = mocked_render.call_args[0][1]
+        assert render_call_dict['errors'] == [msg]
 
-    @ddt.unpack
+    @patch_render
     @ddt.data(
         ('program_uuid', ''),
         ('', 'text'),
         ('', ''),
     )
-    def test_missing_parameter(self, program_uuid, text):
-        msg = u'You must provide both a program uuid and a comma separated list of external_student_key, username'
-        response = self.client.post(self.url, data={
+    @ddt.unpack
+    def test_missing_parameter(self, program_uuid, text, mocked_render):
+        error = (
+            u"You must provide both a program uuid "
+            u"and a series of lines with the format "
+            u"'external_user_key,lms_username'."
+        )
+        self.client.post(self.url, data={
             'program_uuid': program_uuid,
             'text': text,
         })
-        self._assert_props_list('errors', [msg], response)
+        render_call_dict = mocked_render.call_args[0][1]
+        assert render_call_dict['errors'] == [error]
 
     @ddt.data(
         '0001,learner-01\n0002,learner-02',                                 # normal
         '0001,learner-01,apple,orange\n0002,learner-02,purple',             # extra fields
         '\t0001        ,    \t  learner-01    \n   0002 , learner-02    ',  # whitespace
     )
-    @patch('support.views.program_enrollments.link_program_enrollments_to_lms_users')
+    @patch('support.views.program_enrollments.link_program_enrollments')
     def test_text(self, text, mocked_link):
         self.client.post(self.url, data={
             'program_uuid': self.program_uuid,
@@ -508,18 +523,20 @@ class SupportViewLinkProgramEnrollmentsTests(SupportViewTestCase):
         })
         mocked_link.assert_called_once()
         mocked_link.assert_called_with(
-            self.program_uuid,
+            UUID(self.program_uuid),
             {
                 '0001': 'learner-01',
                 '0002': 'learner-02',
             }
         )
 
-    def test_junk_text(self):
+    @patch_render
+    def test_junk_text(self, mocked_render):
         text = 'alsdjflajsdflakjs'
-        response = self.client.post(self.url, data={
+        self.client.post(self.url, data={
             'program_uuid': self.program_uuid,
             'text': text,
         })
-        msg = NO_PROGRAM_ENROLLMENT_TEMPLATE.format(program_uuid=self.program_uuid, external_student_key=text)
-        self._assert_props_list('errors', [msg], response)
+        msg = u"All linking lines must be in the format 'external_user_key,lms_username'"
+        render_call_dict = mocked_render.call_args[0][1]
+        assert render_call_dict['errors'] == [msg]
diff --git a/lms/djangoapps/support/views/program_enrollments.py b/lms/djangoapps/support/views/program_enrollments.py
index 7febaf1c77769240efdc6e45bda7033ef638aa26..32c3eaced8fa4f656bc1e4a26fbf502f5470bbfb 100644
--- a/lms/djangoapps/support/views/program_enrollments.py
+++ b/lms/djangoapps/support/views/program_enrollments.py
@@ -1,17 +1,18 @@
 """
 Support tool for changing course enrollments.
 """
-from __future__ import absolute_import
+from __future__ import absolute_import, unicode_literals
 
 import csv
+from uuid import UUID
+
 from django.utils.decorators import method_decorator
 from django.views.generic import View
 
 from edxmako.shortcuts import render_to_response
+from lms.djangoapps.program_enrollments.api import link_program_enrollments
 from lms.djangoapps.support.decorators import require_support_permission
 
-from lms.djangoapps.program_enrollments.api import link_program_enrollments_to_lms_users
-
 TEMPLATE_PATH = 'support/link_program_enrollments.html'
 
 
@@ -44,25 +45,7 @@ class LinkProgramEnrollmentSupportView(View):
         """
         program_uuid = request.POST.get('program_uuid', '').strip()
         text = request.POST.get('text', '')
-        successes = []
-        errors = []
-        if not program_uuid or not text:
-            error = 'You must provide both a program uuid and a comma separated list of external_student_key, username'
-            errors = [error]
-        else:
-            reader = csv.DictReader(text.splitlines(), fieldnames=('external_key', 'username'))
-            ext_key_to_lms_username = {
-                (item['external_key'] or '').strip(): (item['username'] or '').strip()
-                for item in reader
-            }
-            try:
-                link_errors = link_program_enrollments_to_lms_users(program_uuid, ext_key_to_lms_username)
-            except ValueError as e:
-                errors = [str(e)]
-            else:
-                successes = [str(item) for item in ext_key_to_lms_username.items() if item not in link_errors]
-                errors = [message for message in link_errors.values()]
-
+        successes, errors = self._validate_and_link(program_uuid, text)
         return render_to_response(
             TEMPLATE_PATH,
             {
@@ -72,3 +55,46 @@ class LinkProgramEnrollmentSupportView(View):
                 'text': text,
             }
         )
+
+    @staticmethod
+    def _validate_and_link(program_uuid_string, linkage_text):
+        """
+        Validate arguments, and if valid, call `link_program_enrollments`.
+
+        Returns: (successes, errors)
+            where successes and errors are both list[str]
+        """
+        if not (program_uuid_string and linkage_text):
+            error = (
+                "You must provide both a program uuid "
+                "and a series of lines with the format "
+                "'external_user_key,lms_username'."
+            )
+            return [], [error]
+        try:
+            program_uuid = UUID(program_uuid_string)
+        except ValueError:
+            return [], [
+                "Supplied program UUID '{}' is not a valid UUID.".format(program_uuid_string)
+            ]
+        reader = csv.DictReader(
+            linkage_text.splitlines(), fieldnames=('external_key', 'username')
+        )
+        ext_key_to_username = {
+            (item.get('external_key') or '').strip(): (item['username'] or '').strip()
+            for item in reader
+        }
+        if not (all(ext_key_to_username.keys()) and all(ext_key_to_username.values())):
+            return [], [
+                "All linking lines must be in the format 'external_user_key,lms_username'"
+            ]
+        link_errors = link_program_enrollments(
+            program_uuid, ext_key_to_username
+        )
+        successes = [
+            str(item)
+            for item in ext_key_to_username.items()
+            if item not in link_errors
+        ]
+        errors = [message for message in link_errors.values()]
+        return successes, errors