Repository: carpentries/amy

Issue:
Instructor Selection: resign from upcoming teaching opportunity
As an instructor user, I want to be able to resign from a workshop I have previously applied for.

Acceptance Criteria:
1. "Resign" or "Change option" button should be visible for instructor user on "Upcoming teaching opportunities" list for each workshop they have applied for.
2. The button can only be available for pending applications; once confirmed or declined, instructor has no possibility to resign via AMY interface.
3. Once clicked on the button, user resigns from the workshop (without providing reason).
4. After confirming, a notification snackbar is shown and user's application is withdrawn from the instructor selection for that workshop.

EDIT 2021-10-18: removed reason from pt 3.


Discussion:
Point 3 - we do not want to ask instructors their reason for resigning.
@maneesha is the resign reason optional or should it be completely dropped?
Completely dropped - we do not want to ask about this at all.
@maneesha updated accordingly.

Resolving patch:
diff --git a/amy/dashboard/urls.py b/amy/dashboard/urls.py
--- a/amy/dashboard/urls.py
+++ b/amy/dashboard/urls.py
@@ -41,6 +41,11 @@
                     views.SignupForRecruitment.as_view(),
                     name="signup-for-recruitment",
                 ),
+                path(
+                    "teaching_opportunities/signups/<int:signup_pk>/resign",
+                    views.ResignFromRecruitment.as_view(),
+                    name="resign-from-recruitment",
+                ),
             ]
         ),
     ),
diff --git a/amy/dashboard/views.py b/amy/dashboard/views.py
--- a/amy/dashboard/views.py
+++ b/amy/dashboard/views.py
@@ -7,9 +7,11 @@
 from django.db.models import Case, Count, IntegerField, Prefetch, Q, Value, When
 from django.forms.widgets import HiddenInput
 from django.shortcuts import get_object_or_404, redirect, render
-from django.urls import reverse
+from django.urls import reverse, reverse_lazy
 from django.utils.html import format_html
 from django.views.decorators.http import require_GET
+from django.views.generic import View
+from django.views.generic.detail import SingleObjectMixin
 from django_comments.models import Comment
 
 from autoemails.utils import safe_next_or_default_url
@@ -476,6 +478,54 @@ def form_valid(self, form):
         return super().form_valid(form)
 
 
+class ResignFromRecruitment(
+    LoginRequiredMixin,
+    RecruitmentEnabledMixin,
+    ConditionallyEnabledMixin,
+    SingleObjectMixin,
+    View,
+):
+    permission_required = [
+        "recruitment.view_instructorrecruitmentsignup",
+        "recruitment.delete_instructorrecruitmentsignup",
+    ]
+    default_redirect_url = reverse_lazy("upcoming-teaching-opportunities")
+    pk_url_kwarg = "signup_pk"
+
+    def post(self, request, *args, **kwargs):
+        self.request = request
+
+        signup = self.get_object()
+        recruitment = signup.recruitment
+        signup.delete()
+
+        messages.success(
+            self.request,
+            f"Your teaching request was removed from recruitment {recruitment.event}",
+        )
+
+        redirect_url = self.get_redirect_url()
+        return redirect(redirect_url)
+
+    def get_view_enabled(self) -> bool:
+        try:
+            role = CommunityRole.objects.get(
+                person=self.request.user, config__name="instructor"
+            )
+            return role.is_active() and super().get_view_enabled()
+        except CommunityRole.DoesNotExist:
+            return False
+
+    def get_queryset(self):
+        return InstructorRecruitmentSignup.objects.filter(
+            person=self.request.user, recruitment__status="o"
+        )
+
+    def get_redirect_url(self) -> str:
+        next_url = self.request.POST.get("next", None)
+        return safe_next_or_default_url(next_url, self.default_redirect_url)
+
+
 # ------------------------------------------------------------
 
 


Test patch:
diff --git a/amy/dashboard/tests/test_instructor_recruitment_views.py b/amy/dashboard/tests/test_instructor_recruitment_views.py
--- a/amy/dashboard/tests/test_instructor_recruitment_views.py
+++ b/amy/dashboard/tests/test_instructor_recruitment_views.py
@@ -11,7 +11,11 @@
     CommunityRoleConfig,
     CommunityRoleInactivation,
 )
-from dashboard.views import SignupForRecruitment, UpcomingTeachingOpportunitiesList
+from dashboard.views import (
+    ResignFromRecruitment,
+    SignupForRecruitment,
+    UpcomingTeachingOpportunitiesList,
+)
 from recruitment.models import InstructorRecruitment, InstructorRecruitmentSignup
 from workshops.models import Event, Organization, Person, Role, Task
 
@@ -442,3 +446,148 @@ def test_form_valid__creates_a_new_signup(self, mock_contrib_messages_views):
         )
         # new object created
         self.assertTrue(isinstance(view.object, InstructorRecruitmentSignup))
+
+
+class TestResignFromRecruitment(TestCase):
+    @override_settings(INSTRUCTOR_RECRUITMENT_ENABLED=True)
+    def test_view_enabled__no_community_role(self):
+        # Arrange
+        request = RequestFactory().post("/")
+        request.user = Person(personal="Test", family="User", email="test@user.com")
+        # Act
+        view = ResignFromRecruitment(request=request)
+        # Assert
+        self.assertEqual(view.get_view_enabled(), False)
+
+    @override_settings(INSTRUCTOR_RECRUITMENT_ENABLED=True)
+    def test_view_enabled__community_role_inactive(self):
+        # Arrange
+        request = RequestFactory().post("/")
+        person = Person.objects.create(
+            personal="Test", family="User", email="test@user.com"
+        )
+        request.user = person
+        config = CommunityRoleConfig.objects.create(
+            name="instructor",
+            display_name="Instructor",
+            link_to_award=False,
+            link_to_membership=False,
+            additional_url=False,
+        )
+        inactivation = CommunityRoleInactivation.objects.create(name="inactivation")
+        role = CommunityRole.objects.create(
+            config=config,
+            person=person,
+            inactivation=inactivation,
+        )
+        # Act
+        view = ResignFromRecruitment(request=request)
+        # Assert
+        self.assertEqual(role.is_active(), False)
+        self.assertEqual(view.get_view_enabled(), False)
+
+    @override_settings(INSTRUCTOR_RECRUITMENT_ENABLED=True)
+    def test_view_enabled__community_role_active(self):
+        # Arrange
+        request = RequestFactory().post("/")
+        person = Person.objects.create(
+            personal="Test", family="User", email="test@user.com"
+        )
+        request.user = person
+        config = CommunityRoleConfig.objects.create(
+            name="instructor",
+            display_name="Instructor",
+            link_to_award=False,
+            link_to_membership=False,
+            additional_url=False,
+        )
+        role = CommunityRole.objects.create(
+            config=config,
+            person=person,
+        )
+        # Act
+        view = ResignFromRecruitment(request=request)
+        # Assert
+        self.assertEqual(role.is_active(), True)
+        self.assertEqual(view.get_view_enabled(), True)
+
+    def test_get_queryset(self):
+        # Arrange
+        host = Organization.objects.create(domain="test.com", fullname="Test")
+        person1 = Person.objects.create(
+            personal="Test1", family="User1", email="test1@user1.com", username="test1"
+        )
+        person2 = Person.objects.create(
+            personal="Test2", family="User2", email="test2@user2.com", username="test2"
+        )
+        event1 = Event.objects.create(slug="test-event1", host=host)
+        event2 = Event.objects.create(slug="test-event2", host=host)
+        recruitment1 = InstructorRecruitment.objects.create(status="c", event=event1)
+        recruitment2 = InstructorRecruitment.objects.create(status="o", event=event2)
+        signup1 = InstructorRecruitmentSignup(recruitment=recruitment1, person=person1)
+        signup2 = InstructorRecruitmentSignup(recruitment=recruitment1, person=person2)
+        signup3 = InstructorRecruitmentSignup(recruitment=recruitment2, person=person1)
+        signup4 = InstructorRecruitmentSignup(recruitment=recruitment2, person=person2)
+        InstructorRecruitmentSignup.objects.bulk_create(
+            [signup1, signup2, signup3, signup4]
+        )
+
+        request = RequestFactory().post("/")
+        request.user = person1
+        view = ResignFromRecruitment(request=request)
+
+        # Act
+        signups = view.get_queryset()
+
+        # Assert
+        self.assertEqual(list(signups), [signup3])
+
+    def test_get_redirect_url__no_next_param(self):
+        # Arrange
+        request = RequestFactory().post("/")
+        view = ResignFromRecruitment(request=request)
+        # Act
+        url = view.get_redirect_url()
+        # Assert
+        self.assertEqual(url, reverse("upcoming-teaching-opportunities"))
+
+    def test_get_redirect_url__with_next_param(self):
+        # Arrange
+        next_url = "/dashboard"
+        request = RequestFactory().post("/", {"next": next_url})
+        view = ResignFromRecruitment(request=request)
+        # Act
+        url = view.get_redirect_url()
+        # Assert
+        self.assertEqual(url, next_url)
+
+    @patch("dashboard.views.messages")
+    def test_post(self, mock_messages):
+        # Arrange
+        host = Organization.objects.create(domain="test.com", fullname="Test")
+        person = Person.objects.create(
+            personal="Test", family="User", email="test@user.com"
+        )
+        event = Event.objects.create(
+            slug="test-event", host=host, start=date(2022, 2, 19), end=date(2022, 2, 20)
+        )
+        recruitment = InstructorRecruitment.objects.create(status="o", event=event)
+        signup = InstructorRecruitmentSignup.objects.create(
+            recruitment=recruitment, person=person
+        )
+
+        request = RequestFactory().post("/")
+        request.user = person
+        view = ResignFromRecruitment(kwargs={"signup_pk": signup.pk})
+
+        # Act
+        result = view.post(request)
+
+        # Assert
+        mock_messages.success.assert_called_once_with(
+            request, f"Your teaching request was removed from recruitment {event}"
+        )
+        with self.assertRaises(InstructorRecruitmentSignup.DoesNotExist):
+            signup.refresh_from_db()
+
+        self.assertEqual(result.status_code, 302)