Repository: carpentries/amy

Issue:
Add filter to training requests view for invalid code
Once we implement #2379  we should have an additional filter on the [training requests view](https://amy.carpentries.org/requests/training_requests/) for applications with invalid codes.  

The admin user can then go in and edit the code as needed.


Discussion:

Resolving patch:
diff --git a/amy/extrequests/filters.py b/amy/extrequests/filters.py
--- a/amy/extrequests/filters.py
+++ b/amy/extrequests/filters.py
@@ -29,7 +29,7 @@ def __init__(self, data=None, *args, **kwargs):
         # client-side unless the user deliberately chooses to do so.
         # See https://github.com/carpentries/amy/issues/2314
         if not data:
-            data = QueryDict("state=no_d&matched=u")
+            data = QueryDict("state=pa&matched=u")
 
         super().__init__(data, *args, **kwargs)
 
@@ -39,12 +39,12 @@ def __init__(self, data=None, *args, **kwargs):
     )
 
     member_code = django_filters.CharFilter(
-        field_name="member_code", lookup_expr="icontains", label="Group"
+        field_name="member_code", lookup_expr="icontains", label="Member code"
     )
 
     state = django_filters.ChoiceFilter(
         label="State",
-        choices=(("no_d", "Pending or accepted"),) + TrainingRequest.STATE_CHOICES,
+        choices=(("pa", "Pending or accepted"),) + TrainingRequest.STATE_CHOICES,
         method="filter_training_requests_by_state",
     )
 
@@ -65,6 +65,12 @@ def __init__(self, data=None, *args, **kwargs):
         widget=widgets.CheckboxInput,
     )
 
+    invalid_member_code = django_filters.BooleanFilter(
+        label="Member code marked as invalid",
+        field_name="member_code_override",
+        widget=widgets.CheckboxInput,
+    )
+
     affiliation = django_filters.CharFilter(
         method="filter_affiliation",
     )
@@ -140,8 +146,8 @@ def filter_affiliation(self, queryset, name, affiliation):
             return queryset.filter(q).distinct()
 
     def filter_training_requests_by_state(self, queryset, name, choice):
-        if choice == "no_d":
-            return queryset.exclude(state="d")
+        if choice == "pa":
+            return queryset.filter(state__in=["p", "a"])
         else:
             return queryset.filter(state=choice)
 
diff --git a/amy/workshops/management/commands/fake_database.py b/amy/workshops/management/commands/fake_database.py
--- a/amy/workshops/management/commands/fake_database.py
+++ b/amy/workshops/management/commands/fake_database.py
@@ -306,7 +306,18 @@ def fake_training_request(self, person_or_None):
             state = "a"
             person = person_or_None
 
-        registration_code = self.faker.city() if randbool(0.1) else ""
+        # registration code
+        # default (empty code) is used 25% of the time
+        registration_code = ""
+        override_invalid_code = False
+        if randbool(0.5):
+            # 50% of the time, use an existing code
+            registration_code = choice(Membership.objects.all()).registration_code
+        elif randbool(0.5):
+            # 25% of the time, use an invalid code and the override
+            registration_code = self.faker.city()
+            override_invalid_code = True
+
         occupation = choice(TrainingRequest._meta.get_field("occupation").choices)[0]
         training_completion_agreement = randbool(0.5)
         underrepresented_choices = TrainingRequest._meta.get_field(
@@ -317,6 +328,7 @@ def fake_training_request(self, person_or_None):
             person=person_or_None,
             review_process="preapproved" if registration_code else "open",
             member_code=registration_code,
+            member_code_override=override_invalid_code,
             personal=person.personal,
             middle="",
             family=person.family,


Test patch:
diff --git a/amy/extrequests/tests/test_filters.py b/amy/extrequests/tests/test_filters.py
new file mode 100644
--- /dev/null
+++ b/amy/extrequests/tests/test_filters.py
@@ -0,0 +1,340 @@
+from datetime import datetime, timedelta
+
+from extrequests.filters import TrainingRequestFilter
+from workshops.models import Event, Membership, Role, Tag, Task, TrainingRequest
+from workshops.tests.base import TestBase
+
+
+class TestTrainingRequestFilter(TestBase):
+    """
+    A test should exist for each filter listed in test_fields().
+    """
+
+    def setUp(self) -> None:
+        super().setUp()  # create some persons
+        self._setUpTags()
+        self._setUpRoles()
+
+        self.model = TrainingRequest
+
+        self.membership = Membership.objects.create(
+            name="Alpha Organization",
+            registration_code="valid123",
+            agreement_start=datetime.today(),
+            agreement_end=datetime.today() + timedelta(weeks=52),
+        )
+        self.ttt_event = Event.objects.create(
+            slug="training-event-ttt",
+            host=self.org_alpha,
+        )
+        self.ttt_event.tags.add(Tag.objects.get(name="TTT"))
+
+        # add some training requests
+        # spiderman: open application, accepted and fully matched
+        self.request_spiderman = TrainingRequest.objects.create(
+            person=self.spiderman,
+            review_process="open",
+            personal="Peter",
+            family="Parker",
+            email="peter@webslinger.net",
+            state="a",
+        )
+        Task.objects.create(
+            event=self.ttt_event,
+            person=self.spiderman,
+            role=Role.objects.get(name="learner"),
+        )
+
+        # ironman: preapproved application, pending, matched person
+        self.request_ironman = TrainingRequest.objects.create(
+            person=self.ironman,
+            review_process="preapproved",
+            member_code=self.membership.registration_code,
+            personal="Tony",
+            family="Stark",
+            email="me@stark.com",
+            affiliation="Stark Industries",
+            location="New York City",
+            state="p",
+        )
+
+        # blackwidow: invalid code, discarded, manually scored
+        self.request_blackwidow = TrainingRequest.objects.create(
+            review_process="preapproved",
+            member_code="invalid",
+            member_code_override=True,
+            personal="Natasha",
+            family="Romanova",
+            email="natasha@romanova.com",
+            state="d",
+            score_manual=0,
+        )
+
+        # hermione: withdrawn
+        self.request_hermione = TrainingRequest.objects.create(
+            review_process="open",
+            personal="Hermione",
+            family="Granger",
+            email="hermione@granger.co.uk",
+            state="w",
+        )
+
+        # get filterset
+        self.filterset = TrainingRequestFilter({})
+
+        # get queryset
+        self.qs = TrainingRequest.objects.all()
+
+    def test_fields(self):
+        # Arrange & Act stages happen in setUp()
+        # Assert
+        self.assertEqual(
+            set(self.filterset.filters.keys()),
+            {
+                "search",
+                "member_code",
+                "state",
+                "matched",
+                "nonnull_manual_score",
+                "invalid_member_code",
+                "affiliation",
+                "location",
+                "order_by",
+            },
+        )
+
+    def test_filter_search_name(self):
+        # Arrange
+        filter_name = "search"
+        value = "Peter Parker"
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_spiderman])
+
+    def test_filter_search_email(self):
+        # Arrange
+        filter_name = "search"
+        value = "peter@webslinger.net"
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_spiderman])
+
+    def test_filter_member_code(self):
+        # Arrange
+        filter_name = "member_code"
+        value = "valid123"
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_ironman])
+
+    def test_filter_state__none(self):
+        # Arrange
+        filter_name = "state"
+        value = None
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, self.qs, ordered=False)
+
+    def test_filter_state__pending(self):
+        # Arrange
+        filter_name = "state"
+        value = "p"
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_ironman])
+
+    def test_filter_state__accepted(self):
+        # Arrange
+        filter_name = "state"
+        value = "a"
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_spiderman])
+
+    def test_filter_state__discarded(self):
+        # Arrange
+        filter_name = "state"
+        value = "d"
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_blackwidow])
+
+    def test_filter_state__withdrawn(self):
+        # Arrange
+        filter_name = "state"
+        value = "w"
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_hermione])
+
+    def test_filter_state__pending_or_accepted(self):
+        # Arrange
+        filter_name = "state"
+        value = "pa"
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(
+            result, [self.request_spiderman, self.request_ironman], ordered=False
+        )
+
+    def test_filter_matched__unknown(self):
+        # Arrange
+        filter_name = "matched"
+        value = None
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, self.qs, ordered=False)
+
+    def test_filter_matched__unmatched(self):
+        # Arrange
+        filter_name = "matched"
+        value = "u"
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(
+            result, [self.request_blackwidow, self.request_hermione], ordered=False
+        )
+
+    def test_filter_matched__matched_trainee_unmatched_training(self):
+        # Arrange
+        filter_name = "matched"
+        value = "p"
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_ironman])
+
+    def test_filter_matched__matched_trainee_and_training(self):
+        # Arrange
+        filter_name = "matched"
+        value = "t"
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_spiderman])
+
+    def test_filter_nonnull_manual_score(self):
+        # Arrange
+        filter_name = "nonnull_manual_score"
+        value = True
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_blackwidow])
+
+    def test_filter_invalid_member_code(self):
+        # Arrange
+        filter_name = "invalid_member_code"
+        value = True
+
+        # Act
+        result = self.filterset.filters[filter_name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_blackwidow])
+
+    def test_filter_affiliation(self):
+        # Arrange
+        name = "affiliation"
+        value = "stark"
+
+        # Act
+        result = self.filterset.filters[name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_ironman])
+
+    def test_filter_location(self):
+        # Arrange
+        name = "location"
+        value = "new york"
+
+        # Act
+        result = self.filterset.filters[name].filter(self.qs, value)
+
+        # Assert
+        self.assertQuerysetEqual(result, [self.request_ironman])
+
+    def test_filter_order_by(self):
+        # Arrange
+        filter_name = "order_by"
+        fields = self.filterset.filters[filter_name].param_map
+        results = {}
+        # manually set some scores to order by
+        # blackwidow already has score_manual=0 from setUp()
+        self.request_spiderman.score_manual = 3
+        self.request_spiderman.save()
+        self.request_ironman.score_manual = 2
+        self.request_ironman.save()
+        self.request_hermione.score_manual = 1
+        self.request_hermione.save()
+
+        # default ordering is ascending
+        expected_results = {
+            "created_at": [
+                self.request_spiderman,
+                self.request_ironman,
+                self.request_blackwidow,
+                self.request_hermione,
+            ],
+            "score_total": [
+                self.request_blackwidow,
+                self.request_hermione,
+                self.request_ironman,
+                self.request_spiderman,
+            ],
+        }
+
+        # Act
+        for field in fields.keys():
+            results[field] = self.filterset.filters[filter_name].filter(
+                self.qs, [field]
+            )
+
+        # Assert
+        # we don't have any unexpected fields
+        self.assertEqual(fields.keys(), expected_results.keys())
+        # each field was filtered correctly
+        for field in fields.keys():
+            self.assertQuerysetEqual(
+                results[field], expected_results[field], ordered=True
+            )
diff --git a/amy/extrequests/tests/test_training_request.py b/amy/extrequests/tests/test_training_request.py
--- a/amy/extrequests/tests/test_training_request.py
+++ b/amy/extrequests/tests/test_training_request.py
@@ -292,7 +292,7 @@ def setUp(self):
     def test_view_loads(self):
         """
         View should default to settings:
-            state=no_d (Pending or accepted)
+            state=pa (Pending or accepted)
             matched=u (Unmatched)
         """
         # Act