Repository: carpentries/amy

Issue:
Instructor selection: Can not re-open closed process
When an admin user is in the event view for an event with closed recruitment, there is a link to "See details or re-open." Clicking on the link takes you to the details but there is no way to re-open.


Discussion:

Resolving patch:
diff --git a/amy/recruitment/forms.py b/amy/recruitment/forms.py
--- a/amy/recruitment/forms.py
+++ b/amy/recruitment/forms.py
@@ -48,3 +48,11 @@ class InstructorRecruitmentSignupChangeStateForm(forms.Form):
         ("decline", "Decline"),
     )
     action = forms.ChoiceField(choices=ALLOWED_ACTIONS)
+
+
+class InstructorRecruitmentChangeStateForm(forms.Form):
+    ALLOWED_ACTIONS = (
+        ("close", "Close"),
+        ("reopen", "Reopen"),
+    )
+    action = forms.ChoiceField(choices=ALLOWED_ACTIONS)
diff --git a/amy/recruitment/views.py b/amy/recruitment/views.py
--- a/amy/recruitment/views.py
+++ b/amy/recruitment/views.py
@@ -7,6 +7,7 @@
 from django.contrib.messages.views import SuccessMessageMixin
 from django.db import IntegrityError
 from django.db.models import Case, Count, IntegerField, Prefetch, Q, Value, When
+from django.forms import BaseForm
 from django.http import HttpResponse, HttpResponseRedirect
 from django.shortcuts import get_object_or_404
 from django.urls import reverse
@@ -26,6 +27,7 @@
 from recruitment.filters import InstructorRecruitmentFilter
 from recruitment.forms import (
     InstructorRecruitmentAddSignupForm,
+    InstructorRecruitmentChangeStateForm,
     InstructorRecruitmentCreateForm,
     InstructorRecruitmentSignupChangeStateForm,
 )
@@ -457,6 +459,7 @@ class InstructorRecruitmentChangeState(
 ):
     """POST requests for editing (e.g. closing) the instructor recruitment."""
 
+    form_class = InstructorRecruitmentChangeStateForm
     permission_required = "recruitment.change_instructorrecruitment"
 
     def get_object(self) -> InstructorRecruitment:
@@ -549,14 +552,51 @@ def send_thank_you_to_declined(self, recruitment: InstructorRecruitment) -> None
                     request=self.request,
                 )
 
-    def post(self, request, *args, **kwargs) -> HttpResponse:
-        self.request = request
-        self.object = self.get_object()
+    @staticmethod
+    def _validate_for_reopening(recruitment: InstructorRecruitment) -> bool:
+        if recruitment.status != "c":
+            return False
+        return True
+
+    def reopen_recruitment(self) -> HttpResponse:
+        if not self._validate_for_reopening(self.object):
+            messages.error(
+                self.request,
+                "Unable to re-open recruitment.",
+            )
+
+        else:
+            self.object.status = "o"
+            self.object.save()
+            messages.success(
+                self.request, f"Successfully re-opened recruitment {self.object}."
+            )
+
+        return HttpResponseRedirect(self.get_success_url())
+
+    def form_valid(self, form: BaseForm) -> HttpResponse:
+        action = form.cleaned_data["action"]
 
-        action = "close"
-        # For future if we need to handle other actions
         action_handler_mapping = {
             "close": self.close_recruitment,
+            "reopen": self.reopen_recruitment,
         }
 
         return action_handler_mapping[action]()
+
+    def form_invalid(self, form: BaseForm) -> HttpResponse:
+        messages.error(self.request, "Please choose correct action.")
+        # Note: there's not really a place to redirect user to... So worst-case scenario
+        # they will be redirected to the success URL.
+        referrer = self.request.headers.get("Referer", self.get_success_url())
+        next_url = self.request.POST.get("next", None)
+        return HttpResponseRedirect(safe_next_or_default_url(next_url, referrer))
+
+    def post(self, request, *args, **kwargs) -> HttpResponse:
+        self.request = request
+        self.object = self.get_object()
+        form = self.get_form()
+        if form.is_valid():
+            return self.form_valid(form)
+        else:
+            return self.form_invalid(form)


Test patch:
diff --git a/amy/recruitment/tests/test_forms.py b/amy/recruitment/tests/test_forms.py
--- a/amy/recruitment/tests/test_forms.py
+++ b/amy/recruitment/tests/test_forms.py
@@ -8,6 +8,7 @@
 )
 from recruitment.forms import (
     InstructorRecruitmentAddSignupForm,
+    InstructorRecruitmentChangeStateForm,
     InstructorRecruitmentCreateForm,
     InstructorRecruitmentSignupChangeStateForm,
 )
@@ -111,3 +112,23 @@ def test_clean_success(self) -> None:
         # Assert
         self.assertTrue(form.is_valid())
         self.assertEqual(form.errors.keys(), set())
+
+
+class TestInstructorRecruitmentChangeStateForm(TestCase):
+    def test_empty_payload(self) -> None:
+        # Arrange
+        data = {}
+        # Act
+        form = InstructorRecruitmentChangeStateForm(data)
+        # Assert
+        self.assertFalse(form.is_valid())
+        self.assertEqual(form.errors.keys(), {"action"})
+
+    def test_clean_success(self) -> None:
+        # Arrange
+        data = {"action": "close"}
+        # Act
+        form = InstructorRecruitmentChangeStateForm(data)
+        # Assert
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.errors.keys(), set())
diff --git a/amy/recruitment/tests/test_instructor_recruitment_views.py b/amy/recruitment/tests/test_instructor_recruitment_views.py
--- a/amy/recruitment/tests/test_instructor_recruitment_views.py
+++ b/amy/recruitment/tests/test_instructor_recruitment_views.py
@@ -912,7 +912,7 @@ def tearDown(self):
         recruitment.views.scheduler = self._saved_scheduler
         recruitment.views.redis_connection = self._saved_redis_connection
 
-    def _prepare_email_automation_data(self) -> None:
+    def _prepare_event_and_recruitment(self) -> None:
         Tag.objects.bulk_create(
             [
                 Tag(name="automated-email", priority=0),
@@ -930,6 +930,12 @@ def _prepare_email_automation_data(self) -> None:
             end=date.today() + timedelta(days=8),
         )
         self.event.tags.set(Tag.objects.filter(name__in=["LC", "automated-email"]))
+        self.recruitment = InstructorRecruitment.objects.create(
+            event=self.event, status="c"
+        )
+
+    def _prepare_email_automation_data(self) -> None:
+        self._prepare_event_and_recruitment()
         instructor_role = Role.objects.create(name="instructor")
         host_role = Role.objects.create(name="host")
         person1 = Person.objects.create(
@@ -948,9 +954,6 @@ def _prepare_email_automation_data(self) -> None:
                 Task(person=person3, role=instructor_role, event=self.event),
             ]
         )
-        self.recruitment = InstructorRecruitment.objects.create(
-            event=self.event, status="c"
-        )
         InstructorRecruitmentSignup.objects.create(
             person=person2,
             recruitment=self.recruitment,
@@ -1023,13 +1026,14 @@ def test_get_success_url(self) -> None:
             # Assert
             self.assertEqual(result, redirect)
 
-    def test_post(self) -> None:
+    def test_post__action_close(self) -> None:
         # Arrange
-        request = RequestFactory().post("/")
+        request = RequestFactory().post("/", data={"action": "close"})
         mock_object = mock.MagicMock()
         view = InstructorRecruitmentChangeState(request=request, kwargs={"pk": 11200})
         view.get_object = mock.MagicMock(return_value=mock_object)
         view.close_recruitment = mock.MagicMock()
+        view.reopen_recruitment = mock.MagicMock()
 
         # Act
         view.post(request)
@@ -1038,6 +1042,25 @@ def test_post(self) -> None:
         self.assertEqual(view.request, request)
         self.assertEqual(view.object, mock_object)
         view.close_recruitment.assert_called_once_with()
+        view.reopen_recruitment.assert_not_called()
+
+    def test_post__action_reopen(self) -> None:
+        # Arrange
+        request = RequestFactory().post("/", data={"action": "reopen"})
+        mock_object = mock.MagicMock()
+        view = InstructorRecruitmentChangeState(request=request, kwargs={"pk": 11200})
+        view.get_object = mock.MagicMock(return_value=mock_object)
+        view.close_recruitment = mock.MagicMock()
+        view.reopen_recruitment = mock.MagicMock()
+
+        # Act
+        view.post(request)
+
+        # Assert
+        self.assertEqual(view.request, request)
+        self.assertEqual(view.object, mock_object)
+        view.close_recruitment.assert_not_called()
+        view.reopen_recruitment.assert_called_once_with()
 
     def test__validate_for_closing(self) -> None:
         # Arrange
@@ -1180,8 +1203,64 @@ def test_send_thank_you_to_declined(self) -> None:
             request=view.request,
         )
 
+    def test__validate_for_reopening(self) -> None:
+        # Arrange
+        recruitment1 = InstructorRecruitment(event=None, status="o")
+        recruitment2 = InstructorRecruitment(event=None, status="c")
+        data = [
+            (recruitment1, False),
+            (recruitment2, True),
+            (InstructorRecruitment(event=None, status="o"), False),
+            (InstructorRecruitment(event=None, status="c"), True),
+        ]
+        for R, expected in data:
+            # Act
+            result = InstructorRecruitmentChangeState._validate_for_reopening(R)
+            # Assert
+            self.assertEqual(result, expected)
+
+    def test_reopen_recruitment__failure(self) -> None:
+        # Arrange
+        request = RequestFactory().post("/")
+        view = InstructorRecruitmentChangeState(request=request)
+        view._validate_for_reopening = mock.MagicMock(return_value=False)
+        view.object = mock.MagicMock()
+        view.get_success_url = mock.MagicMock(return_value="")
+
+        # Act
+        with mock.patch("recruitment.views.messages") as mock_messages:
+            result = view.reopen_recruitment()
+
+        # Assert
+        mock_messages.success.assert_not_called()
+        mock_messages.error.assert_called_once_with(
+            request, "Unable to re-open recruitment."
+        )
+        self.assertEqual(result.status_code, 302)
+
+    def test_reopen_recruitment__success(self) -> None:
+        # Arrange
+        request = RequestFactory().post("/")
+        view = InstructorRecruitmentChangeState(request=request)
+        view._validate_for_reopening = mock.MagicMock(return_value=True)
+        view.object = mock.MagicMock()
+        view.get_success_url = mock.MagicMock(return_value="")
+
+        # Act
+        with mock.patch("recruitment.views.messages") as mock_messages:
+            result = view.reopen_recruitment()
+
+        # Assert
+        self.assertEqual(view.object.status, "o")
+        view.object.save.assert_called_once_with()
+        mock_messages.success.assert_called_once_with(
+            request, f"Successfully re-opened recruitment {view.object}."
+        )
+        view.get_success_url.assert_called_once_with()
+        self.assertEqual(result.status_code, 302)
+
     @override_settings(INSTRUCTOR_RECRUITMENT_ENABLED=True)
-    def test_integration(self) -> None:
+    def test_integration__action_close(self) -> None:
         # Arrange
         self._prepare_email_automation_data()
         self.recruitment.status = "o"
@@ -1192,7 +1271,7 @@ def test_integration(self) -> None:
         success_url = reverse("all_instructorrecruitment")
 
         # Act
-        response = self.client.post(url, {}, follow=False)
+        response = self.client.post(url, {"action": "close"}, follow=False)
         self.recruitment.refresh_from_db()
 
         # Assert
@@ -1205,3 +1284,23 @@ def test_integration(self) -> None:
         self.assertTrue(
             RQJob.objects.get(trigger__action=DeclinedInstructorsAction.trigger_name)
         )
+
+    @override_settings(INSTRUCTOR_RECRUITMENT_ENABLED=True)
+    def test_integration__action_reopen(self) -> None:
+        # Arrange
+        self._prepare_event_and_recruitment()
+        self.recruitment.status = "c"
+        self.recruitment.save()
+
+        super()._setUpUsersAndLogin()
+        url = reverse("instructorrecruitment_changestate", args=[self.recruitment.pk])
+        success_url = reverse("all_instructorrecruitment")
+
+        # Act
+        response = self.client.post(url, {"action": "reopen"}, follow=False)
+        self.recruitment.refresh_from_db()
+
+        # Assert
+        self.assertEqual(response.status_code, 302)
+        self.assertRedirects(response, success_url)
+        self.assertEqual(self.recruitment.status, "o")