Repository: svthalia/concrexit

Issue:
Add invited/queued filter on event registrations admin
Add a filter to `/api/v2/admin/events/<pk>/registrations/`, named `queued` or something like that, that filters out registrations that are in the queue.

### Is your feature request related to a problem? Please describe.
The event admin in the app currently shows registrations that are in queue. This is undesired, but there is no filter in the backend, so no neat solution (hence the high priority).




Discussion:
This turns out to be a bit more complicated than I'd hoped, since `queue_position` is not a field. I also found that ordering on queue_position does not work: it results in a 500 error. Looks like we need an annotation with the queue position (both for the filter and the ordering). 

Resolving patch:
diff --git a/website/events/api/v2/admin/filters.py b/website/events/api/v2/admin/filters.py
--- a/website/events/api/v2/admin/filters.py
+++ b/website/events/api/v2/admin/filters.py
@@ -29,7 +29,7 @@ def get_schema_operation_parameters(self, view):
 
 
 class EventRegistrationCancelledFilter(filters.BaseFilterBackend):
-    """Allows you to filter by event registration status."""
+    """Allows you to filter by event registration cancellation status."""
 
     def filter_queryset(self, request, queryset, view):
         cancelled = request.query_params.get("cancelled", None)
@@ -48,7 +48,35 @@ def get_schema_operation_parameters(self, view):
                 "name": "cancelled",
                 "required": False,
                 "in": "query",
-                "description": "Filter by event registration status",
+                "description": "Filter by event registration cancellation status",
+                "schema": {
+                    "type": "boolean",
+                },
+            }
+        ]
+
+
+class EventRegistrationQueuedFilter(filters.BaseFilterBackend):
+    """Allows you to filter by event registration by if they're in the queue."""
+
+    def filter_queryset(self, request, queryset, view):
+        queued = request.query_params.get("queued", None)
+
+        if queued is None:
+            return queryset
+
+        if strtobool(queued):
+            return queryset.exclude(queue_position=None)
+
+        return queryset.filter(queue_position=None)
+
+    def get_schema_operation_parameters(self, view):
+        return [
+            {
+                "name": "queued",
+                "required": False,
+                "in": "query",
+                "description": "Filter by event registration queue position",
                 "schema": {
                     "type": "boolean",
                 },
diff --git a/website/events/api/v2/admin/views.py b/website/events/api/v2/admin/views.py
--- a/website/events/api/v2/admin/views.py
+++ b/website/events/api/v2/admin/views.py
@@ -74,6 +74,7 @@ class EventRegistrationAdminListView(AdminListAPIView, AdminCreateAPIView):
         framework_filters.OrderingFilter,
         framework_filters.SearchFilter,
         filters.EventRegistrationCancelledFilter,
+        filters.EventRegistrationQueuedFilter,
     )
     ordering_fields = ("queue_position", "date", "date_cancelled")
     search_fields = (
diff --git a/website/events/models/event_registration.py b/website/events/models/event_registration.py
--- a/website/events/models/event_registration.py
+++ b/website/events/models/event_registration.py
@@ -1,10 +1,13 @@
 from django.core import validators
 from django.core.exceptions import ValidationError
 from django.db import models
-from django.db.models import Q
+from django.db.models import Q, F, Count
+from django.db.models.functions import NullIf, Greatest
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from django.utils.functional import cached_property
+from queryable_properties.properties import AnnotationProperty
+from queryable_properties.managers import QueryablePropertiesManager
 
 from payments.models import PaymentAmountField
 from .event import Event
@@ -20,6 +23,8 @@ def registration_member_choices_limit():
 class EventRegistration(models.Model):
     """Describes a registration for an Event."""
 
+    objects = QueryablePropertiesManager()
+
     event = models.ForeignKey(Event, models.CASCADE)
 
     member = models.ForeignKey(
@@ -110,17 +115,26 @@ def information_fields(self):
     def is_registered(self):
         return self.date_cancelled is None
 
-    @property
-    def queue_position(self):
-        if self.event.max_participants is not None:
-            try:
-                queue_ids = [
-                    registration.member_id for registration in self.event.queue
-                ]
-                return list(queue_ids).index(self.member_id) + 1
-            except ValueError:
-                pass
-        return None
+    queue_position = AnnotationProperty(
+        # Get queue position by counting amount of registrations with lower date and in case of same date lower id
+        # Subsequently cast to None if this is 0 or lower, in which case it isn't in the queue
+        NullIf(
+            Greatest(
+                Count(
+                    "event__eventregistration",
+                    filter=Q(event__eventregistration__date_cancelled=None)
+                    & (
+                        Q(event__eventregistration__date__lt=F("date"))
+                        | Q(event__eventregistration__id__lte=F("id"))
+                        & Q(event__eventregistration__date__exact=F("date"))
+                    ),
+                )
+                - F("event__max_participants"),
+                0,
+            ),
+            0,
+        )
+    )
 
     @property
     def is_invited(self):


Test patch:
diff --git a/website/events/tests/test_models.py b/website/events/tests/test_models.py
--- a/website/events/tests/test_models.py
+++ b/website/events/tests/test_models.py
@@ -270,11 +270,13 @@ def test_queue_position(self):
         self.assertEqual(self.r2.queue_position, None)
 
         self.r1.event.max_participants = 0
+        self.r1.event.save()
         self.r2.event = self.r1.event
         self.assertEqual(self.r1.queue_position, 1)
         self.assertEqual(self.r2.queue_position, 2)
 
         self.r1.event.max_participants = 1
+        self.r1.event.save()
         self.r2.event = self.r1.event
         self.assertEqual(self.r1.queue_position, None)
         self.assertEqual(self.r2.queue_position, 1)