Repository: svthalia/concrexit

Issue:
Members API doesn't list all members
### Describe the bug
Luko, pk=23 on staging, is listed as member on the website, but is not a member according to the API

### How to reproduce
No idea.
check https://staging.thalia.nu/members/profile/23 vs https://staging.thalia.nu/api/v2/members/23/

### Expected behaviour
These urls should agree on the memberness of Luko




Discussion:
So the website does not say that Luko is a member, but rather that he is a former member. Meanwhile the API uses only current members for the endpoint: https://github.com/svthalia/concrexit/blob/bcfdcac3c33723d72aad309fd06f00889e1020e7/website/members/api/v2/views.py#L49

I agree that it might be nice to allow the API to access former members though.
The actual issue is that he is part of a committee, which causes the app to link try and load his profile when you click on the profile.

Allowing the API to return former members makes sense, maybe when searching this could also be a query parameter like `includeold=true`? 
Yes former members should definitely be available with the detail API. 

And it makes sense to expose the same members to the list API that you see in the member list on website, indeed with a query parameter (I propose `former`)

For the implementor:
https://github.com/svthalia/concrexit/blob/d336b5cb55db0ca9c6b1188bae6789cefee388c3/website/events/api/v2/admin/filters.py has some example filters similar to what will need to be made.

Here's the API views to modify https://github.com/svthalia/concrexit/blob/d336b5cb55db0ca9c6b1188bae6789cefee388c3/website/members/api/v2/views.py#L20-L53

Resolving patch:
diff --git a/website/members/api/v2/filters.py b/website/members/api/v2/filters.py
--- a/website/members/api/v2/filters.py
+++ b/website/members/api/v2/filters.py
@@ -1,4 +1,9 @@
-from rest_framework import filters
+from datetime import datetime
+
+from django.db.models import Q
+from django.utils import timezone
+
+from rest_framework import filters, serializers
 
 from members.models import Membership
 
@@ -28,6 +33,62 @@ def get_schema_operation_parameters(self, view):
         ]
 
 
+class FormerMemberFilter(filters.BaseFilterBackend):
+    def filter_queryset(self, request, queryset, view):
+        former = request.query_params.get("former", "false")
+
+        if former == "false":
+            # Filter out former members
+            return (
+                queryset.exclude(membership=None)
+                .filter(
+                    Q(membership__until__isnull=True)
+                    | Q(membership__until__gt=timezone.now().date())
+                )
+                .distinct()
+            )
+        elif former == "true":
+            # Filter out current members
+
+            memberships_query = Q(until__gt=datetime.now()) | Q(until=None)
+            members_query = ~Q(id=None)
+
+            # Filter out all current active memberships
+            memberships_query &= Q(type=Membership.MEMBER) | Q(type=Membership.HONORARY)
+            memberships = Membership.objects.filter(memberships_query)
+            members_query &= ~Q(pk__in=memberships.values("user__pk"))
+
+            memberships_query = Q(type=Membership.MEMBER) | Q(type=Membership.HONORARY)
+            memberships = Membership.objects.filter(memberships_query)
+            all_memberships = Membership.objects.all()
+            # Only keep members that were once members, or are legacy users
+            # that do not have any memberships at all
+            members_query &= Q(pk__in=memberships.values("user__pk")) | ~Q(
+                pk__in=all_memberships.values("user__pk")
+            )
+
+            return queryset.filter(members_query)
+        elif former == "any":
+            # Include both former and current members
+            return queryset
+        else:
+            raise serializers.ValidationError("invalid former parameter")
+
+    def get_schema_operation_parameters(self, view):
+        return [
+            {
+                "name": "former",
+                "required": False,
+                "in": "query",
+                "description": "Include former members or only former members",
+                "schema": {
+                    "type": "string",
+                    "enum": ["true", "false", "any"],
+                },
+            }
+        ]
+
+
 class MembershipTypeFilter(filters.BaseFilterBackend):
     """Allows you to filter by membership type."""
 
diff --git a/website/members/api/v2/views.py b/website/members/api/v2/views.py
--- a/website/members/api/v2/views.py
+++ b/website/members/api/v2/views.py
@@ -23,7 +23,7 @@ class MemberListView(ListAPIView):
 
     serializer_class = MemberListSerializer
     queryset = (
-        Member.current_members.all()
+        Member.objects.all()
         .select_related("profile")
         .prefetch_related("membership_set")
     )
@@ -43,6 +43,7 @@ def get_serializer(self, *args, **kwargs):
         framework_filters.SearchFilter,
         filters.MembershipTypeFilter,
         filters.StartingYearFilter,
+        filters.FormerMemberFilter,
     )
     ordering_fields = ("first_name", "last_name", "username")
     search_fields = (
@@ -58,7 +59,7 @@ class MemberDetailView(RetrieveAPIView):
     """Returns details of a member."""
 
     serializer_class = MemberSerializer
-    queryset = Member.current_members.all()
+    queryset = Member.objects.all()
     permission_classes = [
         IsAuthenticatedOrTokenHasScope,
     ]


Test patch: