Repository: carpentries/amy

Issue:
Instructor Selection: Additional filter/sort options for Admin view 
On the Instructor Selection [admin view page](https://test-amy.carpentries.org/recruitment/processes/), the admin user can filter by assigned to and by status (open/closed).

We would like to see the following additional options:

* Filter by Online/in-person
* Sort by Priority ascending and descending
* Sort by date ascending and descending 
* Filter by curriculum
* Filter by country 



Discussion:

Resolving patch:
diff --git a/amy/dashboard/filters.py b/amy/dashboard/filters.py
--- a/amy/dashboard/filters.py
+++ b/amy/dashboard/filters.py
@@ -3,7 +3,9 @@
 import django_filters as filters
 
 from recruitment.models import InstructorRecruitment
-from workshops.filters import AMYFilterSet
+from workshops.fields import Select2MultipleWidget
+from workshops.filters import AllCountriesMultipleFilter, AMYFilterSet
+from workshops.models import Curriculum
 
 
 class UpcomingTeachingOpportunitiesFilter(AMYFilterSet):
@@ -23,6 +25,17 @@ class UpcomingTeachingOpportunitiesFilter(AMYFilterSet):
         widget=widgets.CheckboxInput,
     )
 
+    country = AllCountriesMultipleFilter(
+        field_name="event__country", widget=Select2MultipleWidget
+    )
+
+    curricula = filters.ModelMultipleChoiceFilter(
+        field_name="event__curricula",
+        queryset=Curriculum.objects.all(),
+        label="Curriculum",
+        widget=Select2MultipleWidget(),
+    )
+
     order_by = filters.OrderingFilter(
         fields=("event__start",),
         choices=(
diff --git a/amy/recruitment/filters.py b/amy/recruitment/filters.py
--- a/amy/recruitment/filters.py
+++ b/amy/recruitment/filters.py
@@ -1,22 +1,68 @@
-import django_filters
+from django.db.models import QuerySet
+import django_filters as filters
 
-from workshops.fields import ModelSelect2Widget
-from workshops.filters import AMYFilterSet
+from workshops.fields import ModelSelect2Widget, Select2MultipleWidget
+from workshops.filters import AllCountriesMultipleFilter, AMYFilterSet
 from workshops.forms import SELECT2_SIDEBAR
-from workshops.models import Person
+from workshops.models import Curriculum, Person
 
 from .models import InstructorRecruitment
 
 
 class InstructorRecruitmentFilter(AMYFilterSet):
-    assigned_to = django_filters.ModelChoiceFilter(
+    assigned_to = filters.ModelChoiceFilter(
         queryset=Person.objects.all(),
         widget=ModelSelect2Widget(data_view="admin-lookup", attrs=SELECT2_SIDEBAR),
     )
 
+    online_inperson = filters.ChoiceFilter(
+        choices=(
+            ("online", "Online only"),
+            ("inperson", "Inperson only"),
+        ),
+        empty_label="Any",
+        label="Online/inperson",
+        method="filter_online_inperson",
+    )
+
+    country = AllCountriesMultipleFilter(
+        field_name="event__country", widget=Select2MultipleWidget
+    )
+
+    curricula = filters.ModelMultipleChoiceFilter(
+        field_name="event__curricula",
+        queryset=Curriculum.objects.all(),
+        label="Curriculum",
+        widget=Select2MultipleWidget(),
+    )
+
+    order_by = filters.OrderingFilter(
+        fields=("event__start",),
+        choices=(
+            ("-calculated_priority", "Priority"),
+            ("event__start", "Event start"),
+            ("-event__start", "Event start (descending)"),
+        ),
+        method="filter_order_by",
+    )
+
     class Meta:
         model = InstructorRecruitment
         fields = [
             "assigned_to",
             "status",
         ]
+
+    def filter_online_inperson(
+        self, queryset: QuerySet, name: str, value: str
+    ) -> QuerySet:
+        """Filter recruitments based on the event (online/inperson) status."""
+        if value == "online":
+            return queryset.filter(event__tags__name="online")
+        elif value == "inperson":
+            return queryset.exclude(event__tags__name="online")
+        else:
+            return queryset
+
+    def filter_order_by(self, queryset: QuerySet, name: str, values: list) -> QuerySet:
+        return queryset.order_by(*values)


Test patch:
diff --git a/amy/dashboard/tests/test_filters.py b/amy/dashboard/tests/test_filters.py
--- a/amy/dashboard/tests/test_filters.py
+++ b/amy/dashboard/tests/test_filters.py
@@ -15,7 +15,8 @@ def test_fields(self):
         filterset = UpcomingTeachingOpportunitiesFilter(data)
         # Assert
         self.assertEqual(
-            filterset.filters.keys(), {"status", "order_by", "only_applied_to"}
+            filterset.filters.keys(),
+            {"status", "order_by", "only_applied_to", "country", "curricula"},
         )
 
     def test_invalid_values_for_status(self):
diff --git a/amy/recruitment/tests/test_filters.py b/amy/recruitment/tests/test_filters.py
--- a/amy/recruitment/tests/test_filters.py
+++ b/amy/recruitment/tests/test_filters.py
@@ -1,3 +1,6 @@
+from unittest.mock import MagicMock
+
+from django.core.exceptions import ValidationError
 from django.test import TestCase
 
 from recruitment.filters import InstructorRecruitmentFilter
@@ -12,7 +15,17 @@ def test_filters(self) -> None:
         # Act
         keys = filter_set.filters.keys()
         # Assert
-        self.assertEqual(keys, {"assigned_to", "status"})
+        self.assertEqual(
+            keys,
+            {
+                "assigned_to",
+                "status",
+                "online_inperson",
+                "country",
+                "curricula",
+                "order_by",
+            },
+        )
 
     def test_status_filter_choices(self) -> None:
         # Arrange
@@ -49,3 +62,104 @@ def test_assigned_to_filter_choices(self) -> None:
 
         # Assert
         self.assertEqual(list(choices), [person])
+
+    def test_invalid_values_for_online_inperson(self):
+        # Arrange
+        test_data = [
+            "test",
+            "online/inperson",
+            " online",
+        ]
+        filterset = InstructorRecruitmentFilter({})
+        field = filterset.filters["online_inperson"].field
+        # Act
+        for value in test_data:
+            with self.subTest(value=value):
+                # Assert
+                with self.assertRaises(ValidationError):
+                    field.validate(value)
+
+    def test_valid_values_for_online_inperson(self):
+        # Arrange
+        test_data = [
+            "",
+            None,
+            "online",
+            "inperson",
+        ]
+        filterset = InstructorRecruitmentFilter({})
+        field = filterset.filters["online_inperson"].field
+        # Act
+        for value in test_data:
+            with self.subTest(value=value):
+                # Assert no exception
+                field.validate(value)
+
+    def test_invalid_values_for_order_by(self):
+        # Arrange
+        test_data = [
+            "event_start",  # single _ instead of double __
+            "-event__end",
+            "proximity",
+            " event__start",  # space
+            "calculated_priority",  # unavailable
+        ]
+        filterset = InstructorRecruitmentFilter({})
+        field = filterset.filters["order_by"].field
+        # Act
+        for value in test_data:
+            with self.subTest(value=value):
+                # Assert
+                with self.assertRaises(ValidationError):
+                    field.validate(value)
+
+    def test_valid_values_for_order_by(self):
+        # Arrange
+        test_data = [
+            "",
+            None,
+            "event__start",
+            "-event__start",
+            "-calculated_priority",
+        ]
+        filterset = InstructorRecruitmentFilter({})
+        field = filterset.filters["order_by"].field
+        # Act
+        for value in test_data:
+            with self.subTest(value=value):
+                # Assert no exception
+                field.validate(value)
+
+    def test_filter_online_inperson__online(self):
+        # Arrange
+        qs_mock = MagicMock()
+        filterset = InstructorRecruitmentFilter({})
+        name = "status"
+        # Act
+        filterset.filter_online_inperson(qs_mock, name, "online")
+        # Assert
+        qs_mock.filter.assert_called_once_with(event__tags__name="online")
+        qs_mock.exclude.assert_not_called()
+
+    def test_filter_online_inperson__inperson(self):
+        # Arrange
+        qs_mock = MagicMock()
+        filterset = InstructorRecruitmentFilter({})
+        name = "status"
+        # Act
+        filterset.filter_online_inperson(qs_mock, name, "inperson")
+        # Assert
+        qs_mock.filter.assert_not_called()
+        qs_mock.exclude.assert_called_once_with(event__tags__name="online")
+
+    def test_filter_online_inperson__other_value(self):
+        # Arrange
+        qs_mock = MagicMock()
+        filterset = InstructorRecruitmentFilter({})
+        name = "status"
+        # Act
+        result = filterset.filter_online_inperson(qs_mock, name, "other")
+        # Assert
+        qs_mock.filter.assert_not_called()
+        qs_mock.exclude.assert_not_called()
+        self.assertEqual(result, qs_mock)