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

100 statements  

« prev     ^ index     » next       coverage.py v7.6.7, created at 2025-10-29 17:07 +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 

8 

9from members import emails 

10from members.models import Member, Membership 

11from registrations.models import Renewal 

12from utils.snippets import datetime_to_lectureyear 

13 

14 

15def _member_group_memberships( 

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

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

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

19 

20 :return: Object with group memberships 

21 """ 

22 memberships = member.membergroupmembership_set.all() 

23 data = {} 

24 

25 for membership in memberships: 

26 if not condition(membership): 

27 continue 

28 period = { 

29 "since": membership.since, 

30 "until": membership.until, 

31 "chair": membership.chair, 

32 } 

33 

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

35 period["role"] = membership.role 

36 

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

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

39 

40 name = membership.group.name 

41 if data.get(name): 

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

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

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

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

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

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

48 ): 

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

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

51 else: 

52 data[name] = { 

53 "pk": membership.group.pk, 

54 "active": membership.group.active, 

55 "name": name, 

56 "periods": [period], 

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

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

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

60 } 

61 return data 

62 

63 

64def member_achievements(member) -> list: 

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

66 

67 Committee and board memberships + mentorships 

68 """ 

69 achievements = _member_group_memberships( 

70 member, 

71 lambda membership: ( 

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

73 ), 

74 ) 

75 

76 mentor_years = member.mentorship_set.all() 

77 for mentor_year in mentor_years: 

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

79 # Ensure mentorships appear last but are sorted 

80 earliest = date.today() 

81 # Making sure it does not crash in leap years 

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

83 earliest = earliest.replace(day=28) 

84 

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

86 

87 if not achievements.get(name): 

88 achievements[name] = { 

89 "name": name, 

90 "earliest": earliest, 

91 } 

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

93 

94 

95def member_societies(member) -> list: 

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

97 societies = _member_group_memberships( 

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

99 ) 

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

101 

102 

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

104 """Generate statistics about membership types.""" 

105 data = { 

106 "labels": [], 

107 "datasets": [ 

108 {"data": []}, 

109 ], 

110 } 

111 

112 for key, display in Membership.MEMBERSHIP_TYPES: 

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

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

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

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

117 .filter(type=key) 

118 .count() 

119 ) 

120 

121 return data 

122 

123 

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

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

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

127 

128 data = { 

129 "labels": list(years), 

130 "datasets": [ 

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

132 for _, display in Membership.MEMBERSHIP_TYPES 

133 ], 

134 } 

135 

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

137 for year in years: 

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

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

140 .filter( 

141 Q(until__isnull=True) 

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

143 ) 

144 .filter(type=key) 

145 .count() 

146 ) 

147 

148 return data 

149 

150 

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

152 """Generate statistics about active members.""" 

153 return { 

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

155 "datasets": [ 

156 { 

157 "data": [ 

158 Member.active_members.count(), 

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

160 ] 

161 } 

162 ], 

163 } 

164 

165 

166def verify_email_change(change_request) -> None: 

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

168 

169 :param change_request: the email change request 

170 """ 

171 change_request.verified = True 

172 change_request.save() 

173 

174 process_email_change(change_request) 

175 

176 

177def confirm_email_change(change_request) -> None: 

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

179 

180 :param change_request: the email change request 

181 """ 

182 change_request.confirmed = True 

183 change_request.save() 

184 

185 process_email_change(change_request) 

186 

187 

188def process_email_change(change_request) -> None: 

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

190 

191 :param change_request: the email change request 

192 """ 

193 if not change_request.completed: 

194 return 

195 

196 member = change_request.member 

197 member.email = change_request.email 

198 member.save() 

199 

200 emails.send_email_change_completion_message(change_request) 

201 

202 

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

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

205 

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

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

208 :return: list of processed members 

209 """ 

210 if not members: 

211 members = Member.objects 

212 members = ( 

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

214 .exclude( 

215 ( 

216 Q(membership__until__isnull=True) 

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

218 ) 

219 & Q(membership_count__gt=0) 

220 ) 

221 .exclude( 

222 Exists( 

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

224 status__in=( 

225 Renewal.STATUS_COMPLETED, 

226 Renewal.STATUS_REJECTED, 

227 ) 

228 ) 

229 ) 

230 ) 

231 .distinct() 

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

233 ) 

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

235 processed_members = [] 

236 for member in members: 

237 if ( 

238 member.latest_membership is None 

239 or member.latest_membership.until <= deletion_period 

240 ): 

241 processed_members.append(member) 

242 profile = member.profile 

243 profile.student_number = None 

244 profile.phone_number = None 

245 profile.address_street = None 

246 profile.address_street2 = None 

247 profile.address_postal_code = None 

248 profile.address_city = None 

249 profile.address_country = None 

250 profile.birthday = None 

251 profile.emergency_contact_phone_number = None 

252 profile.emergency_contact = None 

253 profile.is_minimized = True 

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

255 profile.save() 

256 

257 return processed_members