Repository: svthalia/concrexit

Issue:
What to do with active members who don't have memberships?
I noticed that we have some/at-least-1 users who don't have an active membership any more, but who are part of a society (or probably also committee). That's not really allowed of course.

I would like to think about and discuss how this should ideally be handled. I'm not sure if we would want to change any behaviour (and even if we decide something _should_ be done, it likely has no priority at all).

The case of committee/society members without a membership occurs naturally, for example for a short time when people forget to renew before september. Hence, I don't think it would be sensible to e.g. automatically end committee memberships at that point. (I also haven't read the HR to check whether people with no membership can be part of a committee or society officially.)

In principle, it would be the intern's responsibility to check for this kind of thing. However, that's probably not feasible, especially for societies. Secondarily, it makes sense that society chairs would be responsible, but those don't quite have an overview of Thalia memberships.

While it's probably not really desirable to prevent committee memberships existing without thalia memberships, we could think about the corresponding permissions on the website, appearance on the committee/society pages, and possibly some kind of warning system.

- Maybe it would be good to not allow people to use committee permissions when they don't have an active membership? On the other hand, there _might_ be cases where we do want non-members in a group? It would be good to check if the HR allows that.

- Non-member group members do currently show up on the website. This is clearly nice for boards, but is also the case for societies and committees. I don't like the idea of hiding non-members as that would be a difference between boards and other groups.

- Would it be worth it to warn people about this. For example, to periodically send an email to committee chairs or the board about active members who don't have a membership any more? This would be pretty easy to implement, but we should only do it if it's valuable.

@WidadMajdoubi24 @JeeVee11 Perhaps you guys should give this some thought (but there's no rush).




Discussion:
We should make the permission backend make it impossible to use committee permissions when you don't have a membership

Resolving patch:
diff --git a/website/activemembers/backends.py b/website/activemembers/backends.py
--- a/website/activemembers/backends.py
+++ b/website/activemembers/backends.py
@@ -31,7 +31,11 @@ def _get_permissions(user, obj):
                     member = Member.objects.get(pk=user.pk)
                 except Member.DoesNotExist:
                     return set()
+
             now = timezone.now()
+            if not member.has_active_membership():
+                return set()
+
             groups = member.membergroup_set.filter(
                 Q(membergroupmembership__until=None)
                 | Q(
diff --git a/website/members/models/member.py b/website/members/models/member.py
--- a/website/members/models/member.py
+++ b/website/members/models/member.py
@@ -11,6 +11,7 @@
 from django.utils.translation import gettext_lazy as _
 
 from activemembers.models import MemberGroup, MemberGroupMembership
+from members.models.membership import Membership
 
 logger = logging.getLogger(__name__)
 
@@ -107,7 +108,7 @@ def refresh_from_db(self, **kwargs):
         return super().refresh_from_db(**kwargs)
 
     @property
-    def current_membership(self):
+    def current_membership(self) -> Membership | None:
         """Return the currently active membership of the user, None if not active.
 
         Warning: this property uses the *cached* `latest_membership`.
@@ -119,7 +120,7 @@ def current_membership(self):
         return membership
 
     @cached_property
-    def latest_membership(self):
+    def latest_membership(self) -> Membership | None:
         """Get the most recent membership of this user.
 
         Warning: this property is cached.
@@ -134,21 +135,21 @@ def latest_membership(self):
         return self.membership_set.latest("since")
 
     @property
-    def earliest_membership(self):
+    def earliest_membership(self) -> Membership | None:
         """Get the earliest membership of this user."""
         if not self.membership_set.exists():
             return None
         return self.membership_set.earliest("since")
 
-    def has_been_member(self):
+    def has_been_member(self) -> bool:
         """Has this user ever been a member?."""
         return self.membership_set.filter(type="member").exists()
 
-    def has_been_honorary_member(self):
+    def has_been_honorary_member(self) -> bool:
         """Has this user ever been an honorary member?."""
         return self.membership_set.filter(type="honorary").exists()
 
-    def has_active_membership(self):
+    def has_active_membership(self) -> bool:
         """Is this member currently active.
 
         Tested by checking if the expiration date has passed.
diff --git a/website/members/models/membership.py b/website/members/models/membership.py
--- a/website/members/models/membership.py
+++ b/website/members/models/membership.py
@@ -78,4 +78,5 @@ def clean(self):
             raise ValidationError(errors)
 
     def is_active(self):
-        return not self.until or self.until > timezone.now().date()
+        today = timezone.now().date()
+        return self.since <= today and (not self.until or self.until > today)


Test patch:
diff --git a/website/activemembers/tests.py b/website/activemembers/tests.py
--- a/website/activemembers/tests.py
+++ b/website/activemembers/tests.py
@@ -8,6 +8,7 @@
 from activemembers.models import Board, Committee, MemberGroupMembership
 from mailinglists.models import MailingList
 from members.models import Member
+from members.models.membership import Membership
 
 
 @override_settings(SUSPEND_SIGNALS=True)
@@ -163,6 +164,11 @@ def setUpTestData(cls):
             until=timezone.now().replace(year=1900),
         )
 
+        for member in [cls.u2, cls.u3, cls.u4, cls.u5]:
+            Membership.objects.create(
+                user=member, type=Membership.MEMBER, since="2000-01-01"
+            )
+
     def test_permissions(self):
         self.assertEqual(3, len(self.u1.get_all_permissions()))
         self.assertEqual(set(), self.u2.get_all_permissions())
@@ -170,6 +176,14 @@ def test_permissions(self):
         self.assertEqual(4, len(self.u4.get_all_permissions()))
         self.assertEqual(set(), self.u5.get_all_permissions())
 
+    def test_committee_member_without_membership(self):
+        self.u1.latest_membership.until = (
+            timezone.now() - timezone.timedelta(days=2)
+        ).date()
+        self.u1.save()
+
+        self.assertEqual(set(), self.u1.get_all_permissions())
+
     def test_nonmember_user(self):
         u = get_user_model().objects.create(username="foo")
         self.assertEqual(set(), u.get_all_permissions())