diff --git a/course/enrollment.py b/course/enrollment.py index cfbc7802c7f891e07d1519509337f6efb173d12a..c688024c00e62bab7cb2fb62daf771ac0fbbb845 100644 --- 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 @@ class EditParticipationForm(StyledModelForm): 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 @@ class EditParticipationForm(StyledModelForm): 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 index 2c9c6ca7cc0336e3f2945a46bf43b5fbc04aa6f5..aa024406614ad8f5f8dffc38840cdca97103670a 100644 --- a/course/versioning.py +++ b/course/versioning.py @@ -179,6 +179,7 @@ class CourseCreationForm(StyledModelForm): "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 index a7fbdb3831685430b0184948cf8074ca32d03204..d3cf224b7889fcab5d0d308ee236a268eda1a875 100644 --- a/local_settings.example.py +++ b/local_settings.example.py @@ -146,6 +146,15 @@ if RELATE_ENABLE_MULTIPLE_SMTP: '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 @@ if RELATE_ENABLE_MULTIPLE_SMTP: NOTIFICATION_EMAIL_FROM = "Notification " GRADER_FEEDBACK_EMAIL_FROM = "Feedback " STUDENT_INTERACT_EMAIL_FROM = "interaction " + ENROLLMENT_EMAIL_FROM = "Enrollment " # }}} diff --git a/tests/base_test_mixins.py b/tests/base_test_mixins.py index 97ba600396c85c45cd4d25b55fd8fa34cd93c844..0e14fd7da7a328de91216907e700757f19fdc2f4 100644 --- a/tests/base_test_mixins.py +++ b/tests/base_test_mixins.py @@ -24,7 +24,7 @@ THE SOFTWARE. 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 @@ SINGLE_COURSE_SETUP_LIST = [ "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 @@ NONE_PARTICIPATION_USER_CREATE_KWARG_LIST = [ "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 @@ NONE_PARTICIPATION_USER_CREATE_KWARG_LIST = [ "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 @@ NONE_PARTICIPATION_USER_CREATE_KWARG_LIST = [ "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 @@ NONE_PARTICIPATION_USER_CREATE_KWARG_LIST = [ "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 @@ class CoursesTestMixinBase(SuperuserCreateMixin): 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 @@ class SingleCourseTestMixin(CoursesTestMixinBase): 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 @@ class SingleCoursePageTestMixin(SingleCourseTestMixin): 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 index 0000000000000000000000000000000000000000..b814b2f71d44034b8428669ad994fedfa9651dab --- /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 index 0000000000000000000000000000000000000000..0a098dbc146f5ff3eae569428aded50b72f92dae --- /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()