Coverage for website/members/models/member.py: 79.31%

96 statements  

« prev     ^ index     » next       coverage.py v7.6.7, created at 2025-08-14 10:31 +0000

1import logging 

2import operator 

3from datetime import timedelta 

4from functools import reduce 

5 

6from django.contrib.auth.models import User, UserManager 

7from django.db.models import Q 

8from django.urls import reverse 

9from django.utils import timezone 

10from django.utils.functional import cached_property 

11from django.utils.translation import gettext_lazy as _ 

12 

13from activemembers.models import MemberGroup, MemberGroupMembership 

14from members.models.membership import Membership 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19class MemberManager(UserManager): 

20 """Get all members, i.e. all users with a profile.""" 

21 

22 def get_queryset(self): 

23 return super().get_queryset().exclude(profile=None) 

24 

25 

26class ActiveMemberManager(MemberManager): 

27 """Get all active members, i.e. who have a committee membership.""" 

28 

29 def get_queryset(self): 

30 """Select all committee members.""" 

31 active_memberships = MemberGroupMembership.active_objects.filter( 

32 group__board=None 

33 ).filter(group__society=None) 

34 

35 return ( 

36 super() 

37 .get_queryset() 

38 .filter(membergroupmembership__in=active_memberships) 

39 .distinct() 

40 ) 

41 

42 

43class CurrentMemberManager(MemberManager): 

44 """Get all members with an active membership.""" 

45 

46 def get_queryset(self): 

47 """Select all members who have a current membership.""" 

48 return ( 

49 super() 

50 .get_queryset() 

51 .exclude(membership=None) 

52 .filter( 

53 Q(membership__until__isnull=True) 

54 | Q(membership__until__gt=timezone.now().date()) 

55 ) 

56 .distinct() 

57 ) 

58 

59 def with_birthdays_in_range(self, from_date, to_date): 

60 """Select all who are currently a Thalia member and have a birthday within the specified range. 

61 

62 :param from_date: the start of the range (inclusive) 

63 :param to_date: the end of the range (inclusive) 

64 :paramtype from_date: datetime 

65 :paramtype to_date: datetime 

66 

67 :return: the filtered queryset 

68 :rtype: Queryset 

69 """ 

70 queryset = self.get_queryset().filter(profile__birthday__lte=to_date) 

71 

72 if (to_date - from_date).days >= 366: 

73 # 366 is important to also account for leap years 

74 # Everyone that's born before to_date has a birthday 

75 return queryset 

76 

77 delta = to_date - from_date 

78 dates = [from_date + timedelta(days=i) for i in range(delta.days + 1)] 

79 monthdays = [ 

80 {"profile__birthday__month": d.month, "profile__birthday__day": d.day} 

81 for d in dates 

82 ] 

83 # Don't get me started (basically, we are making a giant OR query with 

84 # all days and months that are in the range) 

85 query = reduce(operator.or_, [Q(**d) for d in monthdays]) 

86 return queryset.filter(query) 

87 

88 

89class Member(User): 

90 class Meta: 

91 proxy = True 

92 ordering = ("first_name", "last_name") 

93 

94 objects = MemberManager() 

95 current_members = CurrentMemberManager() 

96 active_members = ActiveMemberManager() 

97 

98 def __str__(self): 

99 return f"{self.get_full_name()} ({self.username})" 

100 

101 def refresh_from_db(self, **kwargs): 

102 # Clear the cached current- and latest_membership 

103 if hasattr(self, "_current_membership"): 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true

104 del self._current_membership 

105 if hasattr(self, "current_membership"): 105 ↛ 107line 105 didn't jump to line 107 because the condition on line 105 was always true

106 del self.current_membership 

107 if hasattr(self, "latest_membership"): 107 ↛ 110line 107 didn't jump to line 110 because the condition on line 107 was always true

108 del self.latest_membership 

109 

110 return super().refresh_from_db(**kwargs) 

111 

112 @cached_property 

113 def current_membership(self) -> Membership | None: 

114 """Return the currently active membership of the user, None if not active. 

115 

116 This might not be the latest membership, as a latest membership may be in the future. 

117 

118 Warning: this property is cached. 

119 You can use `refresh_from_db` to clear it. 

120 """ 

121 # Use membership from a Prefetch object if available. 

122 if hasattr(self, "_current_membership"): 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 if not self._current_membership: 

124 return None 

125 return self._current_membership[0] 

126 

127 today = timezone.now().date() 

128 try: 

129 return self.membership_set.filter( 

130 Q(until__isnull=True) | Q(until__gt=today), 

131 since__lte=today, 

132 ).latest("since") 

133 except Membership.DoesNotExist: 

134 return None 

135 

136 @cached_property 

137 def latest_membership(self) -> Membership | None: 

138 """Get the most recent (potentially future) membership of this user. 

139 

140 Warning: this property is cached. 

141 You can use `refresh_from_db` to clear it. 

142 """ 

143 if not self.membership_set.exists(): 

144 return None 

145 return self.membership_set.latest("since") 

146 

147 @property 

148 def earliest_membership(self) -> Membership | None: 

149 """Get the earliest membership of this user.""" 

150 if not self.membership_set.exists(): 

151 return None 

152 return self.membership_set.earliest("since") 

153 

154 def has_been_member(self) -> bool: 

155 """Has this user ever been a member?.""" 

156 return self.membership_set.filter(type="member").exists() 

157 

158 def has_been_honorary_member(self) -> bool: 

159 """Has this user ever been an honorary member?.""" 

160 return self.membership_set.filter(type="honorary").exists() 

161 

162 def has_active_membership(self) -> bool: 

163 """Is this member currently active. 

164 

165 Tested by checking if the expiration date has passed. 

166 """ 

167 return self.current_membership is not None 

168 

169 # Special properties for admin site 

170 has_active_membership.boolean = True 

171 has_active_membership.short_description = _("Is this user currently active") 

172 

173 @classmethod 

174 def all_with_membership(cls, membership_type): 

175 """Get all users who have a specific membership. 

176 

177 :param membership_type: The membership to select by 

178 :return: List of users 

179 :rtype: [Member] 

180 """ 

181 return [ 

182 x 

183 for x in cls.objects.all() 

184 if x.current_membership and x.current_membership.type == membership_type 

185 ] 

186 

187 @property 

188 def can_attend_events(self): 

189 """May this user attend events.""" 

190 if not self.profile: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true

191 return False 

192 

193 return ( 

194 self.profile.event_permissions in ("all", "no_drinks") 

195 and self.has_active_membership() 

196 ) 

197 

198 @property 

199 def can_attend_events_without_membership(self): 

200 if not self.profile: 

201 return False 

202 

203 return self.profile.event_permissions in ("all", "no_drinks") 

204 

205 def get_member_groups(self): 

206 """Get the groups this user is a member of.""" 

207 now = timezone.now() 

208 return MemberGroup.objects.filter( 

209 Q(membergroupmembership__member=self), 

210 Q(membergroupmembership__until=None) 

211 | Q( 

212 membergroupmembership__since__lte=now, 

213 membergroupmembership__until__gte=now, 

214 ), 

215 active=True, 

216 ) 

217 

218 def get_absolute_url(self): 

219 return reverse("members:profile", args=[str(self.pk)])