Repository: inducer/relate

Issue:
Is that a bug: unconfirmed users can request enrollment for courses in some cases
It happens when a course does not require email suffix, I found this when writing tests.


Discussion:
An unconfirmed user would (as far as I know) never be logged in, so I think it's more  a theoretical issue. But it can't hurt to add a check.
I managed to do that by manually changing an active user to be unconfirmed in admin. That's rare of cause.
It's checked here, when the course requires email suffix. https://github.com/inducer/relate/blob/master/course/enrollment.py#L190
Sure--could you just kill the first half of that conditional?
Sure, with tests.

Resolving patch:
diff --git a/course/enrollment.py b/course/enrollment.py
--- a/course/enrollment.py
+++ b/course/enrollment.py
@@ -166,11 +166,34 @@ def enroll_view(request, course_identifier):
     # type: (http.HttpRequest, str) -> http.HttpResponse
 
     course = get_object_or_404(Course, identifier=course_identifier)
-    participation = get_participation_for_request(request, course)
+    user = request.user
+    participations = Participation.objects.filter(course=course, user=user)
+    if not participations.count():
+        participation = None
+    else:
+        participation = participations.first()
 
     if participation is not None:
-        messages.add_message(request, messages.ERROR,
-                _("Already enrolled. Cannot re-renroll."))
+        if participation.status == participation_status.requested:
+            messages.add_message(request, messages.ERROR,
+                                 _("You have previously sent the enrollment "
+                                   "request. Re-sending the request is not "
+                                   "allowed."))
+            return redirect("relate-course_page", course_identifier)
+        elif participation.status == participation_status.denied:
+            messages.add_message(request, messages.ERROR,
+                                 _("Your enrollment request had been denied. "
+                                   "Enrollment is not allowed."))
+            return redirect("relate-course_page", course_identifier)
+        elif participation.status == participation_status.dropped:
+            messages.add_message(request, messages.ERROR,
+                                 _("You had been dropped from the course. "
+                                   "Re-enrollment is not allowed."))
+            return redirect("relate-course_page", course_identifier)
+        else:
+            assert participation.status == participation_status.active
+            messages.add_message(request, messages.ERROR,
+                                 _("Already enrolled. Cannot re-enroll."))
         return redirect("relate-course_page", course_identifier)
 
     if not course.accepts_enrollment:
@@ -185,9 +208,7 @@ def enroll_view(request, course_identifier):
                 _("Can only enroll using POST request"))
         return redirect("relate-course_page", course_identifier)
 
-    user = request.user
-    if (course.enrollment_required_email_suffix
-            and user.status != user_status.active):
+    if user.status != user_status.active:
         messages.add_message(request, messages.ERROR,
                 _("Your email address is not yet confirmed. "
                 "Confirm your email to continue."))
@@ -210,10 +231,18 @@ def enroll_view(request, course_identifier):
                         pass
             pass
 
+    def email_suffix_matches(email, suffix):
+        # type: (Text, Text) -> bool
+        if suffix.startswith("@"):
+            return email.endswith(suffix)
+        else:
+            return email.endswith("@%s" % suffix) or email.endswith(".%s" % suffix)
+
     if (
             preapproval is None
             and course.enrollment_required_email_suffix
-            and not user.email.endswith(course.enrollment_required_email_suffix)):
+            and not email_suffix_matches(
+                user.email, course.enrollment_required_email_suffix)):
 
         messages.add_message(request, messages.ERROR,
                 _("Enrollment not allowed. Please use your '%s' email to "
@@ -248,14 +277,19 @@ def enroll_view(request, course_identifier):
 
                 from django.core.mail import EmailMessage
                 msg = EmailMessage(
-                        string_concat("[%s] ", _("New enrollment request"))
-                        % course_identifier,
-                        message,
-                        settings.ROBOT_EMAIL_FROM,
-                        [course.notify_email])
+                    string_concat("[%s] ", _("New enrollment request"))
+                    % course_identifier,
+                    message,
+                    getattr(settings, "ENROLLMENT_EMAIL_FROM",
+                            settings.ROBOT_EMAIL_FROM),
+                    [course.notify_email])
 
                 from relate.utils import get_outbound_mail_connection
-                msg.connection = get_outbound_mail_connection("robot")
+                msg.connection = (
+                    get_outbound_mail_connection("enroll")
+                    if hasattr(settings, "ENROLLMENT_EMAIL_FROM")
+                    else get_outbound_mail_connection("robot"))
+
                 msg.send()
 
             messages.add_message(request, messages.INFO,
@@ -354,16 +388,27 @@ def send_enrollment_decision(participation, approved, request=None):
             })
 
         from django.core.mail import EmailMessage
+        email_kwargs = {}
+        if settings.RELATE_EMAIL_SMTP_ALLOW_NONAUTHORIZED_SENDER:
+            from_email = course.get_from_email()
+        else:
+            from_email = getattr(settings, "ENROLLMENT_EMAIL_FROM",
+                                 settings.ROBOT_EMAIL_FROM)
+            from relate.utils import get_outbound_mail_connection
+            email_kwargs.update(
+                {"connection": (
+                    get_outbound_mail_connection("enroll")
+                    if hasattr(settings, "ENROLLMENT_EMAIL_FROM")
+                    else get_outbound_mail_connection("robot"))})
+
         msg = EmailMessage(
                 string_concat("[%s] ", _("Your enrollment request"))
                 % course.identifier,
                 message,
-                course.get_from_email(),
-                [participation.user.email])
+                from_email,
+                [participation.user.email],
+                **email_kwargs)
         msg.bcc = [course.notify_email]
-        if not settings.RELATE_EMAIL_SMTP_ALLOW_NONAUTHORIZED_SENDER:
-            from relate.utils import get_outbound_mail_connection
-            msg.connection = get_outbound_mail_connection("robot")
         msg.send()
 
 
@@ -902,6 +947,13 @@ def __init__(self, add_new, pctx, *args, **kwargs):
 
         if not add_new:
             self.fields["user"].disabled = True
+        else:
+            participation_users = Participation.objects.filter(
+                course=participation.course).values_list("user__pk", flat=True)
+            self.fields["user"].queryset = (
+                get_user_model().objects.exclude(pk__in=participation_users)
+            )
+        self.add_new = add_new
 
         may_edit_permissions = pctx.has_permission(pperm.edit_course_permissions)
         if not may_edit_permissions:
@@ -937,6 +989,16 @@ def __init__(self, add_new, pctx, *args, **kwargs):
             self.helper.add_input(
                     Submit("drop", _("Drop"), css_class="btn-danger"))
 
+    def clean_user(self):
+        user = self.cleaned_data["user"]
+        if not self.add_new:
+            return user
+        if user.status == user_status.active:
+            return user
+
+        raise forms.ValidationError(
+            _("This user has not confirmed his/her email."))
+
     def save(self):
         # type: () -> Participation
 
diff --git a/course/versioning.py b/course/versioning.py
--- a/course/versioning.py
+++ b/course/versioning.py
@@ -179,6 +179,7 @@ class Meta:
             "course_file",
             "events_file",
             "enrollment_approval_required",
+            "preapproval_require_verified_inst_id",
             "enrollment_required_email_suffix",
             "from_email",
             "notify_email",
diff --git a/local_settings.example.py b/local_settings.example.py
--- a/local_settings.example.py
+++ b/local_settings.example.py
@@ -146,6 +146,15 @@
             'port': 587,
             'use_tls': True,
         },
+
+        # For enrollment request email sent to course instructors
+        "enroll": {
+            'host': 'smtp.gmail.com',
+            'username': 'blah@blah.com',
+            'password': 'password',
+            'port': 587,
+            'use_tls': True,
+        },
     }
 
     # This will be used as default connection when other keys are not set.
@@ -155,6 +164,7 @@
     NOTIFICATION_EMAIL_FROM = "Notification <notification_example@example.com>"
     GRADER_FEEDBACK_EMAIL_FROM = "Feedback <feedback_example@example.com>"
     STUDENT_INTERACT_EMAIL_FROM = "interaction <feedback_example@example.com>"
+    ENROLLMENT_EMAIL_FROM = "Enrollment <enroll@example.com>"
 
 
 # }}}


Test patch:
diff --git a/tests/base_test_mixins.py b/tests/base_test_mixins.py
--- a/tests/base_test_mixins.py
+++ b/tests/base_test_mixins.py
@@ -24,7 +24,7 @@
 
 import os
 from django.conf import settings
-from django.test import Client
+from django.test import Client, override_settings
 from django.urls import reverse, resolve
 from django.contrib.auth import get_user_model
 from relate.utils import force_remove_path
@@ -53,6 +53,7 @@
             "events_file": "events.yml",
             "enrollment_approval_required": False,
             "enrollment_required_email_suffix": "",
+            "preapproval_require_verified_inst_id": True,
             "from_email": "inform@tiker.net",
             "notify_email": "inform@tiker.net"},
         "participations": [
@@ -98,7 +99,8 @@
         "email": "test_user1@suffix.com",
         "first_name": "Test",
         "last_name": "User1",
-        "institutional_id": "test_user1",
+        "institutional_id": "test_user1_institutional_id",
+        "institutional_id_verified": True,
         "status": user_status.active
     },
     {
@@ -107,7 +109,8 @@
         "email": "test_user2@nosuffix.com",
         "first_name": "Test",
         "last_name": "User2",
-        "institutional_id": "test_user2",
+        "institutional_id": "test_user2_institutional_id",
+        "institutional_id_verified": False,
         "status": user_status.active
     },
     {
@@ -116,7 +119,8 @@
         "email": "test_user3@suffix.com",
         "first_name": "Test",
         "last_name": "User3",
-        "institutional_id": "test_user3",
+        "institutional_id": "test_user3_institutional_id",
+        "institutional_id_verified": True,
         "status": user_status.unconfirmed
     },
     {
@@ -125,7 +129,8 @@
         "email": "test_user4@no_suffix.com",
         "first_name": "Test",
         "last_name": "User4",
-        "institutional_id": "test_user4",
+        "institutional_id": "test_user4_institutional_id",
+        "institutional_id_verified": False,
         "status": user_status.unconfirmed
     }
 ]
@@ -284,40 +289,14 @@ def create_course(cls, **create_course_kwargs):
     def get_course_page_url(cls, course):
         return reverse("relate-course_page", args=[course.identifier])
 
-    def assertResponseMessageCount(self, response, expected_count):  # noqa
-        self.assertEqual(len(list(response.context['messages'])), expected_count)
-
-    def assertResponseMessageContains(self, response, expected_message):  # noqa
-        """
-        :param response: response
-        :param expected_message: message string or list containing message string
-        """
-        if isinstance(expected_message, list):
-            self.assertTrue(set(expected_message).issubset(
-                set([m.message for m in list(response.context['messages'])])))
-        elif isinstance(expected_message, str):
-            self.assertIn(expected_message,
-                          [m.message for m in list(response.context['messages'])])
-
-    def debug_print_response_messages(self, response):
-        """
-        For debugging :class:`django.contrib.messages` objects in post response
-        :param response: response
-        """
-        try:
-            messages = response.context['messages']
-            print("\n-----------message start (%i total)-------------"
-                  % len(messages))
-            for m in list(messages):
-                print(m.message)
-            print("-----------message end-------------\n")
-        except KeyError:
-            print("\n-------no message----------")
-
 
 class SingleCourseTestMixin(CoursesTestMixinBase):
     courses_setup_list = SINGLE_COURSE_SETUP_LIST
 
+    # This is used when there are some attributes which need to be configured
+    # differently from what are in the courses_setup_list
+    course_attributes_extra = {}
+
     @classmethod
     def setUpTestData(cls):  # noqa
         super(SingleCourseTestMixin, cls).setUpTestData()
@@ -345,6 +324,18 @@ def setUpTestData(cls):  # noqa
         cls.c.logout()
         cls.course_page_url = cls.get_course_page_url(cls.course)
 
+        if cls.course_attributes_extra:
+            cls._update_course_attribute()
+
+    @classmethod
+    def _update_course_attribute(cls):
+        # This should be used only in setUpTestData
+        attrs = cls.course_attributes_extra
+        if attrs:
+            assert isinstance(attrs, dict)
+            cls.course.__dict__.update(attrs)
+            cls.course.save()
+
     @classmethod
     def tearDownClass(cls):
         super(SingleCourseTestMixin, cls).tearDownClass()
@@ -421,3 +412,64 @@ def assertSessionScoreEqual(self, expect_score):  # noqa
                                                     Decimal(str(expect_score)))
         else:
             self.assertIsNone(FlowSession.objects.all()[0].points)
+
+
+class FallBackStorageMessageTestMixin(object):
+    # In case other message storage are used, the following is the default
+    # storage used by django and RELATE. Tests which concerns the message
+    # should not include this mixin.
+    storage = 'django.contrib.messages.storage.fallback.FallbackStorage'
+
+    def setUp(self):  # noqa
+        self.settings_override = override_settings(MESSAGE_STORAGE=self.storage)
+        self.settings_override.enable()
+
+    def tearDown(self):  # noqa
+        self.settings_override.disable()
+
+    def get_storage_from_response(self, response):
+        return response.context['messages']
+
+    def get_listed_storage_from_response(self, response):
+        return list(self.get_storage_from_response(response))
+
+    def clear_message_response_storage(self, response):
+        # this should only be used for debug, because we are using private method
+        # which might change
+        storage = self.get_storage_from_response(response)
+        if hasattr(storage, '_loaded_data'):
+            storage._loaded_data = []
+        elif hasattr(storage, '_loaded_message'):
+            storage._loaded_messages = []
+
+        if hasattr(storage, '_queued_messages'):
+            storage._queued_messages = []
+
+        self.assertEqual(len(storage), 0)
+
+    def assertResponseMessagesCount(self, response, expected_count):  # noqa
+        storage = self.get_listed_storage_from_response(response)
+        self.assertEqual(len(storage), expected_count)
+
+    def assertResponseMessagesEqual(self, response, expected_messages):  # noqa
+        storage = self.get_listed_storage_from_response(response)
+        self.assertEqual([m.message for m in storage], expected_messages)
+
+    def assertResponseMessageLevelsEqual(self, response, expected_levels):  # noqa
+        storage = self.get_listed_storage_from_response(response)
+        self.assertEqual([m.level for m in storage], expected_levels)
+
+    def debug_print_response_messages(self, response):
+        """
+        For debugging :class:`django.contrib.messages` objects in post response
+        :param response: response
+        """
+        try:
+            storage = self.get_listed_storage_from_response(response)
+            print("\n-----------message start (%i total)-------------"
+                  % len(storage))
+            for m in storage:
+                print(m.message)
+            print("-----------message end-------------\n")
+        except KeyError:
+            print("\n-------no message----------")
diff --git a/tests/test_enrollment.py b/tests/test_enrollment.py
new file mode 100644
--- /dev/null
+++ b/tests/test_enrollment.py
@@ -0,0 +1,1211 @@
+from __future__ import division
+
+__copyright__ = "Copyright (C) 2017 Dong Zhuang"
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+from django.test import TestCase
+from django.conf import settings
+from django.test.utils import override_settings
+from django.core import mail
+from django.contrib import messages
+from django.contrib.auth import get_user_model
+from django.urls import reverse
+from django.shortcuts import get_object_or_404
+from django.utils.translation import ugettext_lazy as _
+
+from relate.utils import string_concat
+
+from course.models import (
+    Course,
+    Participation, ParticipationRole, ParticipationPreapproval)
+from course.constants import participation_status, user_status
+
+from .base_test_mixins import (
+    SingleCourseTestMixin,
+    NONE_PARTICIPATION_USER_CREATE_KWARG_LIST,
+    FallBackStorageMessageTestMixin
+)
+from .utils import LocmemBackendTestsMixin, mock
+
+
+TEST_EMAIL_SUFFIX1 = "@suffix.com"
+TEST_EMAIL_SUFFIX2 = "suffix.com"
+
+EMAIL_CONNECTIONS = "EMAIL_CONNECTIONS"
+EMAIL_CONNECTION_DEFAULT = "EMAIL_CONNECTION_DEFAULT"
+NO_REPLY_EMAIL_FROM = "NO_REPLY_EMAIL_FROM"
+NOTIFICATION_EMAIL_FROM = "NOTIFICATION_EMAIL_FROM"
+GRADER_FEEDBACK_EMAIL_FROM = "GRADER_FEEDBACK_EMAIL_FROM"
+STUDENT_INTERACT_EMAIL_FROM = "STUDENT_INTERACT_EMAIL_FROM"
+ENROLLMENT_EMAIL_FROM = "ENROLLMENT_EMAIL_FROM"
+
+# {{{ message constants
+
+MESSAGE_ENROLLMENT_SENT_TEXT = _(
+    "Enrollment request sent. You will receive notifcation "
+    "by email once your request has been acted upon.")
+MESSAGE_ENROLL_REQUEST_PENDING_TEXT = _(
+    "Your enrollment request is pending. You will be "
+    "notified once it has been acted upon.")
+MESSAGE_ENROLL_DENIED_NOT_ALLOWED_TEXT = _(
+    "Your enrollment request had been denied. Enrollment is not allowed.")
+MESSAGE_ENROLL_DROPPED_NOT_ALLOWED_TEXT = _(
+    "You had been dropped from the course. Re-enrollment is not allowed.")
+MESSAGE_ENROLL_REQUEST_ALREADY_PENDING_TEXT = _(
+    "You have previously sent the enrollment request. "
+    "Re-sending the request is not allowed.")
+MESSAGE_PARTICIPATION_ALREADY_EXIST_TEXT = _(
+    "A participation already exists. Enrollment attempt aborted.")
+MESSAGE_CANNOT_REENROLL_TEXT = _("Already enrolled. Cannot re-enroll.")
+MESSAGE_SUCCESSFULLY_ENROLLED_TEXT = _("Successfully enrolled.")
+MESSAGE_EMAIL_SUFFIX_REQUIRED_PATTERN = _(
+    "Enrollment not allowed. Please use your '%s' email to enroll.")
+MESSAGE_NOT_ACCEPTING_ENROLLMENTS_TEXT = _("Course is not accepting enrollments.")
+MESSAGE_ENROLL_ONLY_ACCEPT_POST_REQUEST_TEXT = _(
+    "Can only enroll using POST request")
+MESSAGE_ENROLLMENT_DENIED_TEXT = _("Successfully denied.")
+MESSAGE_ENROLLMENT_DROPPED_TEXT = _("Successfully dropped.")
+
+MESSAGE_BATCH_PREAPPROVED_RESULT_PATTERN = _(
+    "%(n_created)d preapprovals created, "
+    "%(n_exist)d already existed, "
+    "%(n_requested_approved)d pending requests approved.")
+
+MESSAGE_EMAIL_NOT_CONFIRMED_TEXT = _(
+    "Your email address is not yet confirmed. "
+    "Confirm your email to continue.")
+MESSAGE_PARTICIPATION_CHANGE_SAVED_TEXT = _("Changes saved.")
+
+EMAIL_NEW_ENROLLMENT_REQUEST_TITLE_PATTERN = (
+    string_concat("[%s] ", _("New enrollment request")))
+EMAIL_ENROLLMENT_DECISION_TITLE_PATTERN = (
+    string_concat("[%s] ", _("Your enrollment request")))
+
+VALIDATION_ERROR_USER_NOT_CONFIRMED = _(
+    "This user has not confirmed his/her email.")
+
+# }}}
+
+
+def course_get_object_or_404_sf_enroll_apprv_not_required(klass, *args, **kwargs):
+    assert klass == Course
+    course_object = get_object_or_404(klass, *args, **kwargs)
+    course_object.enrollment_approval_required = False
+    return course_object
+
+
+def course_get_object_or_404_sf_not_accepts_enrollment(klass, *args, **kwargs):
+    assert klass == Course
+    course_object = get_object_or_404(klass, *args, **kwargs)
+    course_object.accepts_enrollment = False
+    return course_object
+
+
+def course_get_object_or_404_sf_not_email_suffix1(klass, *args, **kwargs):
+    assert klass == Course
+    course_object = get_object_or_404(klass, *args, **kwargs)
+    course_object.enrollment_required_email_suffix = TEST_EMAIL_SUFFIX1
+    return course_object
+
+
+def course_get_object_or_404_sf_not_email_suffix2(klass, *args, **kwargs):
+    assert klass == Course
+    course_object = get_object_or_404(klass, *args, **kwargs)
+    course_object.enrollment_required_email_suffix = TEST_EMAIL_SUFFIX2
+    return course_object
+
+
+class BaseEmailConnectionMixin:
+    EMAIL_CONNECTIONS = None
+    EMAIL_CONNECTION_DEFAULT = None
+    NO_REPLY_EMAIL_FROM = None
+    NOTIFICATION_EMAIL_FROM = None
+    GRADER_FEEDBACK_EMAIL_FROM = None
+    STUDENT_INTERACT_EMAIL_FROM = None
+    ENROLLMENT_EMAIL_FROM = None
+    ROBOT_EMAIL_FROM = "robot@example.com"
+
+    def setUp(self):
+        kwargs = {}
+        for attr in [EMAIL_CONNECTIONS, EMAIL_CONNECTION_DEFAULT,
+                     NO_REPLY_EMAIL_FROM, NOTIFICATION_EMAIL_FROM,
+                     GRADER_FEEDBACK_EMAIL_FROM, STUDENT_INTERACT_EMAIL_FROM,
+                     ENROLLMENT_EMAIL_FROM]:
+            attr_value = getattr(self, attr, None)
+            if attr_value:
+                kwargs.update({attr: attr_value})
+
+        self.settings_email_connection_override = (
+            override_settings(**kwargs))
+        self.settings_email_connection_override.enable()
+
+    def tearDown(self):
+        self.settings_email_connection_override.disable()
+
+
+class EnrollmentTestBaseMixin(SingleCourseTestMixin,
+                              FallBackStorageMessageTestMixin):
+    none_participation_user_create_kwarg_list = (
+        NONE_PARTICIPATION_USER_CREATE_KWARG_LIST)
+
+    @classmethod
+    def setUpTestData(cls):  # noqa
+        super(EnrollmentTestBaseMixin, cls).setUpTestData()
+        assert cls.non_participation_users.count() >= 4
+        cls.non_participation_user1 = cls.non_participation_users[0]
+        cls.non_participation_user2 = cls.non_participation_users[1]
+        cls.non_participation_user3 = cls.non_participation_users[2]
+        cls.non_participation_user4 = cls.non_participation_users[3]
+        if cls.non_participation_user1.status != user_status.active:
+            cls.non_participation_user1.status = user_status.active
+            cls.non_participation_user1.save()
+        if cls.non_participation_user2.status != user_status.active:
+            cls.non_participation_user2.status = user_status.active
+            cls.non_participation_user2.save()
+        if cls.non_participation_user3.status != user_status.unconfirmed:
+            cls.non_participation_user3.status = user_status.unconfirmed
+            cls.non_participation_user3.save()
+        if cls.non_participation_user4.status != user_status.unconfirmed:
+            cls.non_participation_user4.status = user_status.unconfirmed
+            cls.non_participation_user4.save()
+
+    @property
+    def enroll_request_url(self):
+        return reverse("relate-enroll", args=[self.course.identifier])
+
+    @classmethod
+    def get_participation_edit_url(cls, participation_id):
+        return reverse("relate-edit_participation",
+                       args=[cls.course.identifier, participation_id])
+
+    def get_participation_count_by_status(self, status):
+        return Participation.objects.filter(
+            course__identifier=self.course.identifier,
+            status=status
+        ).count()
+
+    @property
+    def student_role_post_data(self):
+        role, _ = (ParticipationRole.objects.get_or_create(
+            course=self.course, identifier="student"))
+        return [str(role.pk)]
+
+
+class EnrollmentRequestTest(
+        LocmemBackendTestsMixin, EnrollmentTestBaseMixin, TestCase):
+
+    course_attributes_extra = {"enrollment_approval_required": True}
+
+    def test_enroll_request_non_participation(self):
+        self.c.force_login(self.non_participation_user1)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 2)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_ENROLLMENT_SENT_TEXT,
+                   MESSAGE_ENROLL_REQUEST_PENDING_TEXT])
+        self.assertResponseMessageLevelsEqual(
+            resp, [messages.INFO, messages.INFO])
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            1)
+
+        # Second and after visits to course page should display only 1 messages
+        resp = self.c.get(self.course_page_url)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_ENROLL_REQUEST_PENDING_TEXT])
+
+        mailmessage = self.get_the_email_message()
+        self.assertEqual(mailmessage["Subject"],
+                         EMAIL_NEW_ENROLLMENT_REQUEST_TITLE_PATTERN
+                         % self.course.identifier)
+
+        self.c.force_login(self.non_participation_user2)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertRedirects(resp, self.course_page_url)
+        self.assertEqual(len(mail.outbox), 2)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            2)
+
+    @mock.patch("course.enrollment.get_object_or_404",
+                side_effect=course_get_object_or_404_sf_enroll_apprv_not_required)
+    def test_enroll_request_non_participation_not_require_approval(
+            self, mocked_get_object_or_404):
+        self.c.force_login(self.non_participation_user1)
+        expected_active_participation_count = (
+            self.get_participation_count_by_status(participation_status.active) + 1)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_SUCCESSFULLY_ENROLLED_TEXT])
+        self.assertResponseMessageLevelsEqual(
+            resp, [messages.SUCCESS])
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            0)
+
+        mailmessage = self.get_the_email_message()
+        self.assertEqual(mailmessage["Subject"],
+                         EMAIL_ENROLLMENT_DECISION_TITLE_PATTERN
+                         % self.course.identifier)
+
+        # Second and after visits to course page should display no messages
+        resp = self.c.get(self.course_page_url)
+        self.assertResponseMessagesCount(resp, 0)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.active),
+            expected_active_participation_count
+        )
+
+    @mock.patch("course.enrollment.get_object_or_404",
+                side_effect=course_get_object_or_404_sf_not_accepts_enrollment)
+    def test_enroll_request_non_participation_course_not_accept_enrollment(
+            self, mocked_get_object_or_404):
+        self.c.force_login(self.non_participation_user1)
+        expected_active_participation_count = (
+            self.get_participation_count_by_status(participation_status.active))
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_NOT_ACCEPTING_ENROLLMENTS_TEXT])
+        self.assertResponseMessageLevelsEqual(
+            resp, [messages.ERROR])
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            0)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.active),
+            expected_active_participation_count
+        )
+
+        # Second and after visits to course page should display no messages
+        resp = self.c.get(self.course_page_url)
+        self.assertResponseMessagesCount(resp, 0)
+
+    # https://github.com/inducer/relate/issues/370
+    def test_pending_user_re_enroll_request_failure(self):
+        self.create_participation(self.course, self.non_participation_user1,
+                                  status=participation_status.requested)
+        self.c.force_login(self.non_participation_user1)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+
+        # Second enroll request won't send more emails,
+        self.assertEqual(len(mail.outbox), 0)
+
+        self.assertResponseMessagesCount(resp, 2)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_ENROLL_REQUEST_ALREADY_PENDING_TEXT,
+                   MESSAGE_ENROLL_REQUEST_PENDING_TEXT])
+
+        self.assertResponseMessageLevelsEqual(
+            resp, [messages.ERROR,
+                   messages.INFO])
+
+    def test_denied_user_enroll_request_failure(self):
+        self.create_participation(self.course, self.non_participation_user1,
+                                  status=participation_status.denied)
+        self.c.force_login(self.non_participation_user1)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_ENROLL_DENIED_NOT_ALLOWED_TEXT])
+
+        self.assertResponseMessageLevelsEqual(
+            resp, [messages.ERROR])
+
+    def test_dropped_user_re_enroll_request_failure(self):
+        self.create_participation(self.course, self.non_participation_user1,
+                                  status=participation_status.dropped)
+        self.c.force_login(self.non_participation_user1)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_ENROLL_DROPPED_NOT_ALLOWED_TEXT])
+
+        self.assertResponseMessageLevelsEqual(
+            resp, [messages.ERROR])
+
+    #  https://github.com/inducer/relate/issues/369
+    def test_unconfirmed_user_enroll_request(self):
+        self.c.force_login(self.non_participation_user4)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_EMAIL_NOT_CONFIRMED_TEXT])
+        self.assertResponseMessageLevelsEqual(resp, [messages.ERROR])
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            0)
+
+    def test_enroll_request_fail_re_enroll(self):
+        self.c.force_login(self.student_participation.user)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_CANNOT_REENROLL_TEXT])
+        self.assertResponseMessageLevelsEqual(resp, [messages.ERROR])
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_enroll_by_get(self):
+        self.c.force_login(self.non_participation_user1)
+        self.c.get(self.enroll_request_url)
+        resp = self.c.get(self.course_page_url)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_ENROLL_ONLY_ACCEPT_POST_REQUEST_TEXT])
+        self.assertResponseMessageLevelsEqual(resp, [messages.ERROR])
+        self.assertEqual(len(mail.outbox), 0)
+
+        # for participations, this show MESSAGE_CANNOT_REENROLL_TEXT
+        self.c.force_login(self.student_participation.user)
+        self.c.get(self.enroll_request_url)
+        resp = self.c.get(self.course_page_url)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_CANNOT_REENROLL_TEXT])
+        self.assertResponseMessageLevelsEqual(resp, [messages.ERROR])
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_edit_participation_view_get_for_requested(self):
+        self.c.force_login(self.non_participation_user1)
+        self.c.post(self.enroll_request_url, follow=True)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            1)
+        my_participation = Participation.objects.get(
+            user=self.non_participation_user1
+        )
+        my_participation_edit_url = (
+            self.get_participation_edit_url(my_participation.pk))
+        resp = self.c.get(my_participation_edit_url)
+        self.assertEqual(resp.status_code, 403)
+
+        self.c.force_login(self.non_participation_user2)
+        resp = self.c.get(my_participation_edit_url)
+        self.assertEqual(resp.status_code, 403)
+
+        self.c.force_login(self.student_participation.user)
+        resp = self.c.get(my_participation_edit_url)
+        self.assertEqual(resp.status_code, 403)
+
+        # only instructor may view edit participation page
+        self.c.force_login(self.instructor_participation.user)
+        resp = self.c.get(my_participation_edit_url)
+        self.assertEqual(resp.status_code, 200)
+        self.assertContains(resp, "submit-id-submit")
+        self.assertContains(resp, "submit-id-approve")
+        self.assertContains(resp, "submit-id-deny")
+
+        self.c.force_login(self.ta_participation.user)
+        resp = self.c.get(my_participation_edit_url)
+        self.assertEqual(resp.status_code, 200)
+        self.assertContains(resp, "submit-id-submit")
+        self.assertContains(resp, "submit-id-approve")
+        self.assertContains(resp, "submit-id-deny")
+
+    def test_edit_participation_view_get_for_enrolled(self):
+        my_participation_edit_url = (
+            self.get_participation_edit_url(self.student_participation.pk))
+        resp = self.c.get(my_participation_edit_url)
+        self.assertEqual(resp.status_code, 403)
+
+        self.c.force_login(self.non_participation_user1)
+        resp = self.c.get(my_participation_edit_url)
+        self.assertEqual(resp.status_code, 403)
+
+        self.c.force_login(self.student_participation.user)
+        resp = self.c.get(my_participation_edit_url)
+        self.assertEqual(resp.status_code, 403)
+
+        # only instructor may view edit participation page
+        self.c.force_login(self.instructor_participation.user)
+        resp = self.c.get(my_participation_edit_url)
+        self.assertEqual(resp.status_code, 200)
+        self.assertContains(resp, "submit-id-submit")
+        self.assertContains(resp, "submit-id-drop")
+
+        self.c.force_login(self.ta_participation.user)
+        resp = self.c.get(my_participation_edit_url)
+        self.assertEqual(resp.status_code, 200)
+        self.assertContains(resp, "submit-id-submit")
+        self.assertContains(resp, "submit-id-drop")
+
+
+class EnrollRequireEmailSuffixTest(LocmemBackendTestsMixin,
+                                   EnrollmentTestBaseMixin, TestCase):
+    course_attributes_extra = {"enrollment_approval_required": True}
+
+    # {{{ email suffix "@suffix.com"
+
+    @mock.patch("course.enrollment.get_object_or_404",
+                side_effect=course_get_object_or_404_sf_not_email_suffix1)
+    def test_email_suffix_matched(self, mocked_email_suffix):
+        self.c.force_login(self.non_participation_user1)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 2)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_ENROLLMENT_SENT_TEXT,
+                   MESSAGE_ENROLL_REQUEST_PENDING_TEXT])
+        self.assertResponseMessageLevelsEqual(
+            resp, [messages.INFO, messages.INFO])
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(
+            self.get_participation_count_by_status(
+                participation_status.requested),
+            1)
+
+    @mock.patch("course.enrollment.get_object_or_404",
+                side_effect=course_get_object_or_404_sf_not_email_suffix1)
+    def test_email_suffix_not_matched(self, mocked_email_suffix):
+        self.c.force_login(self.non_participation_user2)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_EMAIL_SUFFIX_REQUIRED_PATTERN % TEST_EMAIL_SUFFIX1])
+        self.assertResponseMessageLevelsEqual(resp, [messages.ERROR])
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertEqual(
+            self.get_participation_count_by_status(
+                participation_status.requested),
+            0)
+
+    @mock.patch("course.enrollment.get_object_or_404",
+                side_effect=course_get_object_or_404_sf_not_email_suffix1)
+    def test_email_suffix_matched_unconfirmed(self, mocked_email_suffix):
+        self.c.force_login(self.non_participation_user3)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_EMAIL_NOT_CONFIRMED_TEXT])
+        self.assertResponseMessageLevelsEqual(resp, [messages.ERROR])
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertEqual(
+            self.get_participation_count_by_status(
+                participation_status.requested),
+            0)
+
+    @mock.patch("course.enrollment.get_object_or_404",
+                side_effect=course_get_object_or_404_sf_not_email_suffix1)
+    def test_email_suffix_not_matched_unconfirmed(self, mocked_email_suffix):
+        self.c.force_login(self.non_participation_user4)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_EMAIL_NOT_CONFIRMED_TEXT])
+        self.assertResponseMessageLevelsEqual(resp, [messages.ERROR])
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertEqual(
+            self.get_participation_count_by_status(
+                participation_status.requested),
+            0)
+    # }}}
+
+    # {{{ email suffix "suffix.com"
+    @mock.patch("course.enrollment.get_object_or_404",
+                side_effect=course_get_object_or_404_sf_not_email_suffix2)
+    def test_email_suffix_domain_matched(self, mocked_email_suffix):
+        self.c.force_login(self.non_participation_user1)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 2)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_ENROLLMENT_SENT_TEXT,
+                   MESSAGE_ENROLL_REQUEST_PENDING_TEXT])
+        self.assertResponseMessageLevelsEqual(
+            resp, [messages.INFO, messages.INFO])
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(
+            self.get_participation_count_by_status(
+                participation_status.requested),
+            1)
+
+    @mock.patch("course.enrollment.get_object_or_404",
+                side_effect=course_get_object_or_404_sf_not_email_suffix2)
+    def test_email_suffix_domain_not_matched(self, mocked_email_suffix):
+        self.c.force_login(self.non_participation_user2)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_EMAIL_SUFFIX_REQUIRED_PATTERN % TEST_EMAIL_SUFFIX2])
+        self.assertResponseMessageLevelsEqual(resp, [messages.ERROR])
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertEqual(
+            self.get_participation_count_by_status(
+                participation_status.requested),
+            0)
+
+    @mock.patch("course.enrollment.get_object_or_404",
+                side_effect=course_get_object_or_404_sf_not_email_suffix2)
+    def test_email_suffix_domain_matched_unconfirmed(self, mocked_email_suffix):
+        self.c.force_login(self.non_participation_user3)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_EMAIL_NOT_CONFIRMED_TEXT])
+        self.assertResponseMessageLevelsEqual(resp, [messages.ERROR])
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertEqual(
+            self.get_participation_count_by_status(
+                participation_status.requested),
+            0)
+
+    @mock.patch("course.enrollment.get_object_or_404",
+                side_effect=course_get_object_or_404_sf_not_email_suffix2)
+    def test_email_suffix_dot_domain_matched(self, mocked_email_suffix):
+        test_user5 = self.create_user({
+            "username": "test_user5",
+            "password": "test_user5",
+            "email": "test_user5@some.suffix.com",
+            "first_name": "Test",
+            "last_name": "User5",
+            "status": user_status.active
+        })
+        self.c.force_login(test_user5)
+        resp = self.c.post(self.enroll_request_url, follow=True)
+        self.assertResponseMessagesCount(resp, 2)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_ENROLLMENT_SENT_TEXT,
+                   MESSAGE_ENROLL_REQUEST_PENDING_TEXT])
+        self.assertResponseMessageLevelsEqual(
+            resp, [messages.INFO, messages.INFO])
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(
+            self.get_participation_count_by_status(
+                participation_status.requested),
+            1)
+        get_user_model().objects.get(pk=test_user5.pk).delete()
+    # }}}
+
+
+class EnrollmentDecisionTestMixin(LocmemBackendTestsMixin, EnrollmentTestBaseMixin):
+    course_attributes_extra = {"enrollment_approval_required": True}
+
+    @classmethod
+    def setUpTestData(cls):  # noqa
+        super(EnrollmentDecisionTestMixin, cls).setUpTestData()
+        my_participation = cls.create_participation(
+            cls.course, cls.non_participation_user1,
+            status=participation_status.requested)
+        time_factor = [str(my_participation.time_factor)]
+        roles = [str(r.pk) for r in my_participation.roles.all()]
+        notes = [str(my_participation.notes)]
+
+        cls.my_participation_edit_url = (
+            cls.get_participation_edit_url(my_participation.pk))
+
+        form_data = {"time_factor": time_factor,
+                     "roles": roles, "notes": notes}
+        cls.approve_post_data = {"approve": [""]}
+        cls.approve_post_data.update(form_data)
+        cls.deny_post_data = {"deny": [""]}
+        cls.deny_post_data.update(form_data)
+        cls.drop_post_data = {"drop": [""]}
+        cls.drop_post_data.update(form_data)
+
+
+class EnrollmentDecisionTest(EnrollmentDecisionTestMixin, TestCase):
+    course_attributes_extra = {"enrollment_approval_required": True}
+
+    @property
+    def add_new_url(self):
+        return self.get_participation_edit_url(-1)
+
+    def test_edit_participation_view_enroll_decision_approve(self):
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            1)
+        self.c.force_login(self.instructor_participation.user)
+        resp = self.c.post(self.my_participation_edit_url, self.approve_post_data)
+        self.assertEqual(resp.status_code, 200)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            0)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_SUCCESSFULLY_ENROLLED_TEXT])
+        self.assertResponseMessageLevelsEqual(resp, [messages.SUCCESS])
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            0)
+
+    def test_edit_participation_view_enroll_decision_approve_no_permission1(self):
+        self.c.force_login(self.student_participation.user)
+        resp = self.c.post(self.my_participation_edit_url, self.approve_post_data)
+        self.assertEqual(resp.status_code, 403)
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            1)
+
+    def test_edit_participation_view_enroll_decision_approve_no_permission2(self):
+        self.c.force_login(self.non_participation_user1)
+        resp = self.c.post(self.my_participation_edit_url, self.approve_post_data)
+        self.assertEqual(resp.status_code, 403)
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            1)
+
+    def test_edit_participation_view_enroll_decision_deny(self):
+        self.c.force_login(self.instructor_participation.user)
+        resp = self.c.post(self.my_participation_edit_url, self.deny_post_data)
+        self.assertEqual(resp.status_code, 200)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            0)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_ENROLLMENT_DENIED_TEXT])
+        self.assertResponseMessageLevelsEqual(resp, [messages.SUCCESS])
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            0)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.denied),
+            1)
+
+    def test_edit_participation_view_enroll_decision_drop(self):
+        self.c.force_login(self.instructor_participation.user)
+        self.create_participation(self.course, self.non_participation_user3,
+                                  status=participation_status.active)
+        resp = self.c.post(self.my_participation_edit_url, self.drop_post_data)
+        self.assertEqual(resp.status_code, 200)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.dropped),
+            1)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_ENROLLMENT_DROPPED_TEXT])
+        self.assertResponseMessageLevelsEqual(resp, [messages.SUCCESS])
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_edit_participation_view_add_new_unconfirmed_user(self):
+        self.c.force_login(self.instructor_participation.user)
+        resp = self.c.get(self.add_new_url)
+        self.assertTrue(resp.status_code, 200)
+
+        if self.non_participation_user3.status != user_status.unconfirmed:
+            self.non_participation_user3.status = user_status.unconfirmed
+            self.non_participation_user3.save()
+
+        expected_active_user_count = (
+            get_user_model()
+            .objects.filter(status=user_status.unconfirmed).count())
+
+        expected_active_participation_count = (
+            self.get_participation_count_by_status(participation_status.active))
+
+        form_data = {"user": [str(self.non_participation_user3.pk)],
+                     "time_factor": 1,
+                     "roles": self.student_role_post_data, "notes": [""],
+                     "add_new": True
+                     }
+        add_post_data = {"submit": [""]}
+        add_post_data.update(form_data)
+        resp = self.c.post(self.add_new_url, add_post_data, follow=True)
+        self.assertFormError(resp, 'form', 'user',
+                             VALIDATION_ERROR_USER_NOT_CONFIRMED)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.active),
+            expected_active_participation_count)
+
+        self.assertEqual(
+            get_user_model()
+            .objects.filter(status=user_status.unconfirmed).count(),
+            expected_active_user_count)
+        self.assertResponseMessagesCount(resp, 0)
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_edit_participation_view_add_new_active_user(self):
+        self.c.force_login(self.instructor_participation.user)
+        resp = self.c.get(self.add_new_url)
+        self.assertTrue(resp.status_code, 200)
+
+        if self.non_participation_user4.status != user_status.active:
+            self.non_participation_user4.status = user_status.active
+            self.non_participation_user4.save()
+
+        expected_active_user_count = (
+            get_user_model()
+            .objects.filter(status=user_status.unconfirmed).count()
+        )
+
+        expected_active_participation_count = (
+            self.get_participation_count_by_status(participation_status.active) + 1
+        )
+
+        form_data = {"user": [str(self.non_participation_user4.pk)],
+                     "time_factor": 1,
+                     "roles": self.student_role_post_data, "notes": [""],
+                     "add_new": True
+                     }
+        add_post_data = {"submit": [""]}
+        add_post_data.update(form_data)
+        resp = self.c.post(self.add_new_url, add_post_data, follow=True)
+        self.assertEqual(resp.status_code, 200)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.active),
+            expected_active_participation_count)
+
+        self.assertEqual(
+            get_user_model()
+            .objects.filter(status=user_status.unconfirmed).count(),
+            expected_active_user_count)
+        self.assertResponseMessagesEqual(
+            resp, [MESSAGE_PARTICIPATION_CHANGE_SAVED_TEXT])
+        self.assertResponseMessageLevelsEqual(
+            resp, [messages.SUCCESS])
+        self.assertResponseMessagesCount(resp, 1)
+        self.assertEqual(len(mail.outbox), 0)
+
+    def test_edit_participation_view_add_new_invalid_choice(self):
+        self.c.force_login(self.instructor_participation.user)
+        form_data = {"user": [str(self.student_participation.user.pk)],
+                     "time_factor": 0.5,
+                     "roles": self.student_role_post_data, "notes": [""],
+                     "add_new": True
+                     }
+        add_post_data = {"submit": [""]}
+        add_post_data.update(form_data)
+        resp = self.c.post(self.add_new_url, add_post_data, follow=True)
+        from django.forms.models import ModelChoiceField
+        self.assertFormError(
+            resp, 'form', 'user',
+            ModelChoiceField.default_error_messages['invalid_choice'])
+
+    def test_edit_participation_view_enroll_decision_deny_no_permission1(self):
+        self.c.force_login(self.student_participation.user)
+        resp = self.c.post(self.my_participation_edit_url, self.deny_post_data)
+        self.assertEqual(resp.status_code, 403)
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            1)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.denied),
+            0)
+
+    def test_edit_participation_view_enroll_decision_deny_no_permission2(self):
+        self.c.force_login(self.non_participation_user1)
+        resp = self.c.post(self.my_participation_edit_url, self.deny_post_data)
+        self.assertEqual(resp.status_code, 403)
+        self.assertEqual(len(mail.outbox), 0)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.requested),
+            1)
+        self.assertEqual(
+            self.get_participation_count_by_status(participation_status.denied),
+            0)
+
+
+class EnrollmentPreapprovalTestMixin(LocmemBackendTestsMixin,
+                                     EnrollmentTestBaseMixin):
+
+    @classmethod
+    def setUpTestData(cls):  # noqa
+        super(EnrollmentPreapprovalTestMixin, cls).setUpTestData()
+        assert cls.non_participation_user1.institutional_id_verified is True
+        assert cls.non_participation_user2.institutional_id_verified is False
+
+    @property
+    def preapprove_data_emails(self):
+        preapproved_user = [self.non_participation_user1,
+                            self.non_participation_user2]
+        preapproved_data = [u.email for u in preapproved_user]
+        return preapproved_data
+
+    @property
+    def preapprove_data_institutional_ids(self):
+        preapproved_user = [self.non_participation_user1,
+                            self.non_participation_user2,
+                            self.non_participation_user3]
+        preapproved_data = [u.institutional_id for u in preapproved_user]
+        return preapproved_data
+
+    @property
+    def preapproval_url(self):
+        return reverse("relate-create_preapprovals",
+                            args=[self.course.identifier])
+
+    @property
+    def default_preapprove_role(self):
+        role, _ = (ParticipationRole.objects.get_or_create(
+            course=self.course, identifier="student"))
+        return [str(role.pk)]
+
+    def post_preapprovel(self, user, preapproval_type, preapproval_data=None):
+        self.c.force_login(user)
+        if preapproval_data is None:
+            if preapproval_type == "email":
+                preapproval_data = self.preapprove_data_emails
+            elif preapproval_type == "institutional_id":
+                preapproval_data = self.preapprove_data_institutional_ids
+
+        assert preapproval_data is not None
+        assert isinstance(preapproval_data, list)
+
+        data = {
+            "preapproval_type": [preapproval_type],
+            "preapproval_data": ["\n".join(preapproval_data)],
+            "roles": self.student_role_post_data,
+            "submit": [""]
+        }
+        resp = self.c.post(self.preapproval_url, data, follow=True)
+        self.c.logout()
+        return resp
+
+    def get_preapproval_count(self):
+        return ParticipationPreapproval.objects.all().count()
+
+
+class EnrollmentPreapprovalTest(EnrollmentPreapprovalTestMixin, TestCase):
+    course_attributes_extra = {
+        "enrollment_approval_required": True,
+        "preapproval_require_verified_inst_id": True}
+
+    def test_preapproval_url_get(self):
+        self.c.force_login(self.instructor_participation.user)
+        resp = self.c.get(self.preapproval_url)
+        self.assertTrue(resp.status_code, 200)
+
+    def test_preapproval_create_email_type(self):
+        resp = self.post_preapprovel(
+            self.instructor_participation.user,
+            "email",
+            self.preapprove_data_emails)
+        self.assertEqual(
+            self.get_preapproval_count(), len(self.preapprove_data_emails))
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_BATCH_PREAPPROVED_RESULT_PATTERN
+             % {
+                 'n_created': len(self.preapprove_data_emails),
+                 'n_exist': 0,
+                 'n_requested_approved': 0
+             }]
+        )
+
+        # repost same data
+        resp = self.post_preapprovel(
+            self.instructor_participation.user,
+            "email",
+            self.preapprove_data_emails)
+        self.assertEqual(
+            self.get_preapproval_count(), len(self.preapprove_data_emails))
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_BATCH_PREAPPROVED_RESULT_PATTERN
+             % {
+                 'n_created': 0,
+                 'n_exist': len(self.preapprove_data_emails),
+                 'n_requested_approved': 0
+             }]
+        )
+
+    def test_preapproval_create_institutional_id_type(self):
+        resp = self.post_preapprovel(
+            self.instructor_participation.user, "institutional_id",
+            self.preapprove_data_institutional_ids)
+        self.assertEqual(
+            self.get_preapproval_count(),
+            len(self.preapprove_data_institutional_ids))
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_BATCH_PREAPPROVED_RESULT_PATTERN
+             % {
+                 'n_created': len(self.preapprove_data_institutional_ids),
+                 'n_exist': 0,
+                 'n_requested_approved': 0
+             }]
+        )
+
+        # repost same data
+        resp = self.post_preapprovel(
+            self.instructor_participation.user, "institutional_id",
+            self.preapprove_data_institutional_ids)
+        self.assertEqual(
+            self.get_preapproval_count(),
+            len(self.preapprove_data_institutional_ids))
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_BATCH_PREAPPROVED_RESULT_PATTERN
+             % {
+                 'n_created': 0,
+                 'n_exist': len(self.preapprove_data_institutional_ids),
+                 'n_requested_approved': 0
+             }]
+        )
+
+    def test_preapproval_create_permission_error(self):
+        self.c.force_login(self.student_participation.user)
+        resp = self.c.get(self.preapproval_url)
+        self.assertEqual(resp.status_code, 403)
+        resp = self.post_preapprovel(
+            self.student_participation.user,
+            "email",
+            self.preapprove_data_emails)
+        self.assertEqual(
+            self.get_preapproval_count(), 0)
+        self.assertEqual(resp.status_code, 403)
+
+    def test_preapproval_email_type_approve_pendings(self):
+        enroll_request_users = [self.non_participation_user1]
+        for u in enroll_request_users:
+            self.c.force_login(u)
+            self.c.post(self.enroll_request_url, follow=True)
+            self.c.logout()
+        self.flush_mailbox()
+        expected_participation_count = (
+            self.get_participation_count_by_status(participation_status.active) + 1)
+        resp = self.post_preapprovel(
+            self.instructor_participation.user, "email",
+            self.preapprove_data_emails)
+        self.assertEqual(
+            self.get_participation_count_by_status(
+                participation_status.active), expected_participation_count)
+
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_BATCH_PREAPPROVED_RESULT_PATTERN
+             % {
+                 'n_created': len(self.preapprove_data_emails),
+                 'n_exist': 0,
+                 'n_requested_approved': len(enroll_request_users)
+             }]
+        )
+        self.assertEqual(
+            len([m.to for m in mail.outbox]), len(enroll_request_users))
+
+    def test_preapproval_inst_id_type_approve_pending_require_id_verified(self):
+        assert self.course.preapproval_require_verified_inst_id is True
+        enroll_request_users = [
+            self.non_participation_user1, self.non_participation_user2]
+        for u in enroll_request_users:
+            self.c.force_login(u)
+            self.c.post(self.enroll_request_url, follow=True)
+            self.c.logout()
+        self.flush_mailbox()
+        n_expected_newly_enrolled_users = (
+            len([u for u in enroll_request_users if u.institutional_id_verified]))
+        expected_participation_count = (
+            self.get_participation_count_by_status(participation_status.active)
+            + n_expected_newly_enrolled_users
+        )
+        resp = self.post_preapprovel(
+            self.instructor_participation.user, "institutional_id",
+            self.preapprove_data_institutional_ids)
+        self.assertEqual(
+            self.get_participation_count_by_status(
+                participation_status.active), expected_participation_count)
+
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_BATCH_PREAPPROVED_RESULT_PATTERN
+             % {
+                 'n_created': len(self.preapprove_data_institutional_ids),
+                 'n_exist': 0,
+                 'n_requested_approved': n_expected_newly_enrolled_users
+             }]
+        )
+        self.assertEqual(
+            len([m.to for m in mail.outbox]), n_expected_newly_enrolled_users)
+
+
+class EnrollmentPreapprovalInstIdNotRequireVerifiedTest(
+                                        EnrollmentPreapprovalTestMixin, TestCase):
+
+    # We'll have to mock course at two place if use mock, so I separate this
+    # test out of EnrollmentPreapprovalTest
+    course_attributes_extra = {
+        "enrollment_approval_required": True,
+        "preapproval_require_verified_inst_id": False}
+
+    def test_preapproval_inst_id_type_approve_pending_not_require_id_verified(self):
+        assert self.course.preapproval_require_verified_inst_id is False
+        enroll_request_users = [
+            self.non_participation_user1, self.non_participation_user2]
+        for u in enroll_request_users:
+            self.c.force_login(u)
+            self.c.post(self.enroll_request_url, follow=True)
+            self.c.logout()
+        self.flush_mailbox()
+        n_expected_newly_enrolled_users = len(enroll_request_users)
+        expected_participation_count = (
+            self.get_participation_count_by_status(participation_status.active)
+            + n_expected_newly_enrolled_users
+        )
+        resp = self.post_preapprovel(
+            self.instructor_participation.user, "institutional_id",
+            self.preapprove_data_institutional_ids)
+        self.assertEqual(
+            self.get_participation_count_by_status(
+                participation_status.active), expected_participation_count)
+
+        self.assertResponseMessagesEqual(
+            resp,
+            [MESSAGE_BATCH_PREAPPROVED_RESULT_PATTERN
+             % {
+                 'n_created': len(self.preapprove_data_institutional_ids),
+                 'n_exist': 0,
+                 'n_requested_approved': n_expected_newly_enrolled_users
+             }]
+        )
+
+        self.assertEqual(
+            len([m.to for m in mail.outbox]), n_expected_newly_enrolled_users)
+
+
+class EnrollmentEmailConnectionsTestMixin(LocmemBackendTestsMixin):
+    #  Ensure request/decision mail will be sent with/without EmailConnection
+    # settings. https://github.com/inducer/relate/pull/366
+    course_attributes_extra = {"enrollment_approval_required": True}
+
+    email_connections = {
+        "enroll": {
+            'host': 'smtp.gmail.com',
+            'username': 'blah@blah.com',
+            'password': 'password',
+            'port': 587,
+            'use_tls': True,
+        },
+    }
+
+    email_connections_none = {}
+    enrollment_email_from = "enroll@example.com"
+    robot_email_from = "robot@example.com"
+
+
+class EnrollmentRequestEmailConnectionsTest(
+            EnrollmentEmailConnectionsTestMixin, EnrollmentTestBaseMixin, TestCase):
+
+    def test_email_with_email_connections1(self):
+        # with EMAIL_CONNECTIONS and ENROLLMENT_EMAIL_FROM configured
+        with self.settings(
+                EMAIL_CONNECTIONS=self.email_connections,
+                ROBOT_EMAIL_FROM=self.robot_email_from,
+                ENROLLMENT_EMAIL_FROM=self.enrollment_email_from):
+
+            expected_from_email = settings.ENROLLMENT_EMAIL_FROM
+
+            self.c.force_login(self.non_participation_user1)
+            self.c.post(self.enroll_request_url, follow=True)
+            msg = mail.outbox[0]
+            self.assertEqual(msg.from_email, expected_from_email)
+
+    def test_email_with_email_connections3(self):
+        # with neither EMAIL_CONNECTIONS nor ENROLLMENT_EMAIL_FROM configured
+        with self.settings(
+                EMAIL_CONNECTIONS=self.email_connections,
+                ROBOT_EMAIL_FROM=self.robot_email_from):
+            if hasattr(settings, ENROLLMENT_EMAIL_FROM):
+                del settings.ENROLLMENT_EMAIL_FROM
+
+            expected_from_email = settings.ROBOT_EMAIL_FROM
+
+            self.c.force_login(self.non_participation_user1)
+            self.c.post(self.enroll_request_url, follow=True)
+            msg = mail.outbox[0]
+            self.assertEqual(msg.from_email, expected_from_email)
+
+
+class EnrollmentDecisionEmailConnectionsTest(
+        EnrollmentEmailConnectionsTestMixin, EnrollmentDecisionTestMixin, TestCase):
+
+    # {{{ with EMAIL_CONNECTIONS and ENROLLMENT_EMAIL_FROM configured
+    def test_email_with_email_connections1(self):
+        with self.settings(
+                RELATE_EMAIL_SMTP_ALLOW_NONAUTHORIZED_SENDER=False,
+                EMAIL_CONNECTIONS=self.email_connections,
+                ROBOT_EMAIL_FROM=self.robot_email_from,
+                ENROLLMENT_EMAIL_FROM=self.enrollment_email_from):
+
+            expected_from_email = settings.ENROLLMENT_EMAIL_FROM
+
+            self.c.force_login(self.instructor_participation.user)
+            self.c.post(self.my_participation_edit_url, self.approve_post_data)
+
+            msg = mail.outbox[0]
+            self.assertEqual(msg.from_email, expected_from_email)
+
+    def test_email_with_email_connections2(self):
+        with self.settings(
+                RELATE_EMAIL_SMTP_ALLOW_NONAUTHORIZED_SENDER=True,
+                EMAIL_CONNECTIONS=self.email_connections,
+                ROBOT_EMAIL_FROM=self.robot_email_from,
+                ENROLLMENT_EMAIL_FROM=self.enrollment_email_from):
+
+            expected_from_email = self.course.from_email
+
+            self.c.force_login(self.instructor_participation.user)
+            self.c.post(self.my_participation_edit_url, self.approve_post_data)
+
+            msg = mail.outbox[0]
+            self.assertEqual(msg.from_email, expected_from_email)
+    # }}}
+
+    # {{{ with neither EMAIL_CONNECTIONS nor ENROLLMENT_EMAIL_FROM configured
+    def test_email_with_email_connections3(self):
+        with self.settings(
+                RELATE_EMAIL_SMTP_ALLOW_NONAUTHORIZED_SENDER=False,
+                ROBOT_EMAIL_FROM=self.robot_email_from):
+            if hasattr(settings, EMAIL_CONNECTIONS):
+                del settings.EMAIL_CONNECTIONS
+            if hasattr(settings, ENROLLMENT_EMAIL_FROM):
+                del settings.ENROLLMENT_EMAIL_FROM
+
+            expected_from_email = settings.ROBOT_EMAIL_FROM
+
+            self.c.force_login(self.instructor_participation.user)
+            self.c.post(self.my_participation_edit_url, self.approve_post_data)
+            msg = mail.outbox[0]
+            self.assertEqual(msg.from_email, expected_from_email)
+
+    def test_email_with_email_connections4(self):
+        with self.settings(
+                RELATE_EMAIL_SMTP_ALLOW_NONAUTHORIZED_SENDER=True,
+                ROBOT_EMAIL_FROM=self.robot_email_from):
+            if hasattr(settings, EMAIL_CONNECTIONS):
+                del settings.EMAIL_CONNECTIONS
+            if hasattr(settings, ENROLLMENT_EMAIL_FROM):
+                del settings.ENROLLMENT_EMAIL_FROM
+
+            expected_from_email = self.course.from_email
+
+            self.c.force_login(self.instructor_participation.user)
+            self.c.post(self.my_participation_edit_url, self.approve_post_data)
+            msg = mail.outbox[0]
+            self.assertEqual(msg.from_email, expected_from_email)
+    # }}}
+
+
+# vim: foldmethod=marker
diff --git a/tests/utils.py b/tests/utils.py
new file mode 100644
--- /dev/null
+++ b/tests/utils.py
@@ -0,0 +1,103 @@
+# These are copied (and maybe modified) from django official unit tests
+
+from __future__ import division
+from django.test import override_settings
+from django.core import mail
+
+try:
+    from unittest import mock  # noqa
+except:
+    import mock  # noqa
+
+
+class BaseEmailBackendTestsMixin(object):
+    email_backend = None
+
+    def setUp(self):  # noqa
+        self.settings_override = override_settings(EMAIL_BACKEND=self.email_backend)
+        self.settings_override.enable()
+
+    def tearDown(self):  # noqa
+        self.settings_override.disable()
+
+    def assertStartsWith(self, first, second):  # noqa
+        if not first.startswith(second):
+            self.longMessage = True
+            self.assertEqual(first[:len(second)], second,
+                             "First string doesn't start with the second.")
+
+    def get_mailbox_content(self):
+        raise NotImplementedError(
+            'subclasses of BaseEmailBackendTests must provide '
+            'a get_mailbox_content() method')
+
+    def flush_mailbox(self):
+        raise NotImplementedError('subclasses of BaseEmailBackendTests may '
+                                  'require a flush_mailbox() method')
+
+    def get_the_email_message(self):
+        mailbox = self.get_mailbox_content()
+        self.assertEqual(
+            len(mailbox), 1,
+            "Expected exactly one message, got %d.\n%r"
+            % (len(mailbox), [m.as_string() for m in mailbox])
+        )
+        return mailbox[0]
+
+    def get_the_latest_message(self):
+        mailbox = self.get_mailbox_content()
+        self.assertGreater(
+            len(mailbox), 0,
+            "Expected at least one message, got %d.\n%r"
+            % (len(mailbox), [m.as_string() for m in mailbox])
+        )
+        return mailbox[-1]
+
+    def debug_print_email_messages(self, indices=None):
+        """
+        For debugging  print email contents with indices in outbox
+        """
+        messages = self.get_mailbox_content()
+        if indices is not None:
+            if not isinstance(indices, list):
+                assert isinstance(indices, int)
+                indices = [indices]
+            else:
+                for i in indices:
+                    assert isinstance(i, int)
+        else:
+            indices = list(range(len(messages)))
+        for i in indices:
+            try:
+                msg = messages[i]
+                print("\n-----------email (%i)-------------" % i)
+                print(msg)
+            except KeyError:
+                print("\n-------no email with index %i----------" % i)
+            finally:
+                print("\n------------------------")
+
+
+class LocmemBackendTestsMixin(BaseEmailBackendTestsMixin):
+    email_backend = 'django.core.mail.backends.locmem.EmailBackend'
+
+    def get_mailbox_content(self):
+        return [m.message() for m in mail.outbox]
+
+    def flush_mailbox(self):
+        mail.outbox = []
+
+    def tearDown(self):  # noqa
+        super(LocmemBackendTestsMixin, self).tearDown()
+        mail.outbox = []
+
+
+class BaseEmailBackendTestsMixin(object):
+    email_backend = None
+
+    def setUp(self):  # noqa
+        self.settings_override = override_settings(EMAIL_BACKEND=self.email_backend)
+        self.settings_override.enable()
+
+    def tearDown(self):  # noqa
+        self.settings_override.disable()