Coverage for website/members/services.py: 53.68%

100 statements  

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

1from collections.abc import Callable 

2from datetime import date 

3from typing import Any 

4 

5from django.conf import settings 

6from django.db.models import Count, Exists, OuterRef, Q 

7from django.utils import timezone 

8from members import emails 

9from members.models import Member, Membership 

10from registrations.models import Renewal 

11from utils.snippets import datetime_to_lectureyear 

12 

13 

14def _member_group_memberships( 

15 member: Member, condition: Callable[[Membership], bool] 

16) -> dict[str, dict[str, Any]]: 

17 """Determine the group membership of a user based on a condition. 

18 

19 :return: Object with group memberships 

20 """ 

21 memberships = member.membergroupmembership_set.all() 

22 data = {} 

23 

24 for membership in memberships: 

25 if not condition(membership): 

26 continue 

27 period = { 

28 "since": membership.since, 

29 "until": membership.until, 

30 "chair": membership.chair, 

31 } 

32 

33 if hasattr(membership.group, "board"): 

34 period["role"] = membership.role 

35 

36 if membership.until is None and hasattr(membership.group, "board"): 

37 period["until"] = membership.group.board.until 

38 

39 name = membership.group.name 

40 if data.get(name): 

41 data[name]["periods"].append(period) 

42 if data[name]["earliest"] > period["since"]: 

43 data[name]["earliest"] = period["since"] 

44 if period["until"] is None or ( 

45 data[name]["latest"] is not None 

46 and data[name]["latest"] < period["until"] 

47 ): 

48 data[name]["latest"] = period["until"] 

49 data[name]["periods"].sort(key=lambda x: x["since"]) 

50 else: 

51 data[name] = { 

52 "pk": membership.group.pk, 

53 "active": membership.group.active, 

54 "name": name, 

55 "periods": [period], 

56 "url": settings.BASE_URL + membership.group.get_absolute_url(), 

57 "earliest": period["since"], 

58 "latest": period["until"], 

59 } 

60 return data 

61 

62 

63def member_achievements(member) -> list: 

64 """Derive a list of achievements of a member. 

65 

66 Committee and board memberships + mentorships 

67 """ 

68 achievements = _member_group_memberships( 

69 member, 

70 lambda membership: ( 

71 hasattr(membership.group, "board") or hasattr(membership.group, "committee") 

72 ), 

73 ) 

74 

75 mentor_years = member.mentorship_set.all() 

76 for mentor_year in mentor_years: 

77 name = f"Mentor in {mentor_year.year}" 

78 # Ensure mentorships appear last but are sorted 

79 earliest = date.today() 

80 # Making sure it does not crash in leap years 

81 if earliest.month == 2 and earliest.day == 29: 

82 earliest = earliest.replace(day=28) 

83 

84 earliest = earliest.replace(year=earliest.year + mentor_year.year) 

85 

86 if not achievements.get(name): 

87 achievements[name] = { 

88 "name": name, 

89 "earliest": earliest, 

90 } 

91 return sorted(achievements.values(), key=lambda x: x["earliest"]) 

92 

93 

94def member_societies(member) -> list: 

95 """Derive a list of societies a member was part of.""" 

96 societies = _member_group_memberships( 

97 member, lambda membership: (hasattr(membership.group, "society")) 

98 ) 

99 return sorted(societies.values(), key=lambda x: x["earliest"]) 

100 

101 

102def gen_stats_member_type() -> dict[str, list]: 

103 """Generate statistics about membership types.""" 

104 data = { 

105 "labels": [], 

106 "datasets": [ 

107 {"data": []}, 

108 ], 

109 } 

110 

111 for key, display in Membership.MEMBERSHIP_TYPES: 

112 data["labels"].append(str(display)) 

113 data["datasets"][0]["data"].append( 

114 Membership.objects.filter(since__lte=date.today()) 

115 .filter(Q(until__isnull=True) | Q(until__gt=date.today())) 

116 .filter(type=key) 

117 .count() 

118 ) 

119 

120 return data 

121 

122 

123def gen_stats_year() -> dict[str, list]: 

124 """Generate statistics on how many members (and other membership types) there were in each year.""" 

125 years = range(2015, datetime_to_lectureyear(date.today()) + 1) 

126 

127 data = { 

128 "labels": list(years), 

129 "datasets": [ 

130 {"label": str(display), "data": []} 

131 for _, display in Membership.MEMBERSHIP_TYPES 

132 ], 

133 } 

134 

135 for index, (key, _) in enumerate(Membership.MEMBERSHIP_TYPES): 

136 for year in years: 

137 data["datasets"][index]["data"].append( 

138 Membership.objects.filter(since__lte=date(year=year, month=10, day=1)) 

139 .filter( 

140 Q(until__isnull=True) 

141 | Q(until__gt=date(year=year, month=10, day=1)) 

142 ) 

143 .filter(type=key) 

144 .count() 

145 ) 

146 

147 return data 

148 

149 

150def gen_stats_active_members() -> dict[str, list]: 

151 """Generate statistics about active members.""" 

152 return { 

153 "labels": ["Active Members", "Non-active Members"], 

154 "datasets": [ 

155 { 

156 "data": [ 

157 Member.active_members.count(), 

158 Member.current_members.count() - Member.active_members.count(), 

159 ] 

160 } 

161 ], 

162 } 

163 

164 

165def verify_email_change(change_request) -> None: 

166 """Mark the email change request as verified. 

167 

168 :param change_request: the email change request 

169 """ 

170 change_request.verified = True 

171 change_request.save() 

172 

173 process_email_change(change_request) 

174 

175 

176def confirm_email_change(change_request) -> None: 

177 """Mark the email change request as verified. 

178 

179 :param change_request: the email change request 

180 """ 

181 change_request.confirmed = True 

182 change_request.save() 

183 

184 process_email_change(change_request) 

185 

186 

187def process_email_change(change_request) -> None: 

188 """Change the user's email address if the request was completed and send the completion email. 

189 

190 :param change_request: the email change request 

191 """ 

192 if not change_request.completed: 

193 return 

194 

195 member = change_request.member 

196 member.email = change_request.email 

197 member.save() 

198 

199 emails.send_email_change_completion_message(change_request) 

200 

201 

202def execute_data_minimisation(dry_run=False, members=None) -> list[Member]: 

203 """Clean the profiles of members/users of whom the last membership ended at least 90 days ago. 

204 

205 :param dry_run: does not really remove data if True 

206 :param members: queryset of members to process, optional 

207 :return: list of processed members 

208 """ 

209 if not members: 

210 members = Member.objects 

211 members = ( 

212 members.annotate(membership_count=Count("membership")) 

213 .exclude( 

214 ( 

215 Q(membership__until__isnull=True) 

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

217 ) 

218 & Q(membership_count__gt=0) 

219 ) 

220 .exclude( 

221 Exists( 

222 Renewal.objects.filter(member__id=OuterRef("pk")).exclude( 

223 status__in=( 

224 Renewal.STATUS_ACCEPTED, 

225 Renewal.STATUS_REJECTED, 

226 ) 

227 ) 

228 ) 

229 ) 

230 .distinct() 

231 .prefetch_related("membership_set", "profile") 

232 ) 

233 deletion_period = timezone.now().date() - timezone.timedelta(days=90) 

234 processed_members = [] 

235 for member in members: 

236 if ( 

237 member.latest_membership is None 

238 or member.latest_membership.until <= deletion_period 

239 ): 

240 processed_members.append(member) 

241 profile = member.profile 

242 profile.student_number = None 

243 profile.phone_number = None 

244 profile.address_street = None 

245 profile.address_street2 = None 

246 profile.address_postal_code = None 

247 profile.address_city = None 

248 profile.address_country = None 

249 profile.birthday = None 

250 profile.emergency_contact_phone_number = None 

251 profile.emergency_contact = None 

252 profile.is_minimized = True 

253 if not dry_run: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 profile.save() 

255 

256 return processed_members