Coverage for website/activemembers/models.py: 81.61%

183 statements  

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

1"""The models defined by the activemembers package.""" 

2 

3import datetime 

4import logging 

5 

6from django.conf import settings 

7from django.contrib.admin import display as admin_display 

8from django.contrib.auth.models import Permission 

9from django.core.exceptions import NON_FIELD_ERRORS, ValidationError 

10from django.core.files.storage import storages 

11from django.core.validators import MinValueValidator 

12from django.db import models 

13from django.urls import reverse 

14from django.utils import timezone 

15from django.utils.translation import gettext_lazy as _ 

16 

17from thumbnails.fields import ImageField 

18from tinymce.models import HTMLField 

19 

20from utils.media.services import get_upload_to_function 

21from utils.snippets import overlaps 

22 

23logger = logging.getLogger(__name__) 

24 

25 

26class ActiveMemberGroupManager(models.Manager): 

27 """Returns active objects only sorted by the localized name.""" 

28 

29 def get_queryset(self): 

30 return super().get_queryset().exclude(active=False).order_by("name") 

31 

32 

33class MemberGroup(models.Model): 

34 """Describes a groups of members.""" 

35 

36 objects = models.Manager() 

37 active_objects = ActiveMemberGroupManager() 

38 

39 name = models.CharField(max_length=40, verbose_name=_("Name"), unique=True) 

40 

41 description = HTMLField(verbose_name=_("Description")) 

42 

43 photo = ImageField( 

44 verbose_name=_("Image"), 

45 resize_source_to="source", 

46 upload_to=get_upload_to_function("committeephotos"), 

47 storage=storages["public"], 

48 null=True, 

49 blank=True, 

50 ) 

51 

52 members = models.ManyToManyField( 

53 "members.Member", through="activemembers.MemberGroupMembership" 

54 ) 

55 

56 permissions = models.ManyToManyField( 

57 Permission, 

58 verbose_name=_("permissions"), 

59 blank=True, 

60 related_name="permissions_groups", 

61 ) 

62 

63 chair_permissions = models.ManyToManyField( 

64 Permission, 

65 verbose_name=_("chair permissions"), 

66 blank=True, 

67 help_text="Permissions only for certain members of a committee", 

68 related_name="chair_permissions_groups", 

69 ) 

70 

71 since = models.DateField( 

72 _("founded in"), 

73 null=True, 

74 blank=True, 

75 ) 

76 

77 until = models.DateField( 

78 _("existed until"), 

79 null=True, 

80 blank=True, 

81 ) 

82 

83 active = models.BooleanField( 

84 default=False, 

85 help_text=_( 

86 "This should only be unchecked if the committee has been " 

87 "dissolved. The websites assumes that any committees on it" 

88 " existed at some point." 

89 ), 

90 ) 

91 

92 contact_email = models.EmailField( 

93 _("contact email address"), 

94 blank=True, 

95 null=True, 

96 ) 

97 

98 contact_mailinglist = models.OneToOneField( 

99 "mailinglists.MailingList", 

100 verbose_name=_("contact mailing list"), 

101 null=True, 

102 blank=True, 

103 on_delete=models.SET_NULL, 

104 ) 

105 

106 display_members = models.BooleanField( 

107 default=False, 

108 help_text="With this enabled, the members of this committee will be visible to anyone without logging in. Logged-in users can always view the list of members.", 

109 ) 

110 

111 @property 

112 @admin_display(description=_("email")) 

113 def contact_address(self): 

114 if self.contact_mailinglist: 

115 return f"{self.contact_mailinglist.name}@{settings.SITE_DOMAIN}" 

116 return self.contact_email 

117 

118 def __init__(self, *args, **kwargs): 

119 super().__init__(*args, **kwargs) 

120 if self.photo: 

121 self._orig_image = self.photo.name 

122 else: 

123 self._orig_image = None 

124 

125 def save(self, **kwargs): 

126 super().save(**kwargs) 

127 storage = self.photo.storage 

128 

129 if self._orig_image and self._orig_image != self.photo.name: 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true

130 storage.delete(self._orig_image) 

131 self._orig_image = None 

132 

133 def delete(self, using=None, keep_parents=False): 

134 if self.photo.name: 

135 self.photo.delete() 

136 return super().delete(using, keep_parents) 

137 

138 def clean(self): 

139 if ( 

140 self.contact_email is not None and self.contact_mailinglist is not None 

141 ) or (self.contact_email is None and self.contact_mailinglist is None): 

142 raise ValidationError( 

143 { 

144 "contact_email": _( 

145 "Please use either the mailing list or email address option." 

146 ), 

147 "contact_mailinglist": _( 

148 "Please use either the mailing list or email address option." 

149 ), 

150 } 

151 ) 

152 

153 def __str__(self): 

154 return str(self.name) 

155 

156 def get_absolute_url(self): 

157 try: 

158 return self.board.get_absolute_url() 

159 except self.DoesNotExist: 

160 try: 

161 return self.committee.get_absolute_url() 

162 except self.DoesNotExist: 

163 try: 

164 return self.society.get_absolute_url() 

165 except self.DoesNotExist: 

166 raise NotImplementedError( 

167 f"get_absolute_url() not implemented for {self.__class__.__name__}" 

168 ) 

169 

170 class Meta: 

171 verbose_name = _("member group") 

172 verbose_name_plural = _("member groups") 

173 ordering = ["name"] 

174 

175 

176class Committee(MemberGroup): 

177 """Describes a committee, which is a type of MemberGroup.""" 

178 

179 objects = models.Manager() 

180 active_objects = ActiveMemberGroupManager() 

181 

182 def get_absolute_url(self): 

183 return reverse("activemembers:committee", args=[str(self.pk)]) 

184 

185 class Meta: 

186 verbose_name = _("committee") 

187 verbose_name_plural = _("committees") 

188 # ordering is done in the manager, to sort on a translated field 

189 

190 

191class Society(MemberGroup): 

192 """Describes a society, which is a type of MemberGroup.""" 

193 

194 objects = models.Manager() 

195 active_objects = ActiveMemberGroupManager() 

196 

197 def get_absolute_url(self): 

198 return reverse("activemembers:society", args=[str(self.pk)]) 

199 

200 class Meta: 

201 verbose_name = _("society") 

202 verbose_name_plural = _("societies") 

203 # ordering is done in the manager, to sort on a translated field 

204 

205 

206class Board(MemberGroup): 

207 """Describes a board, which is a type of MemberGroup.""" 

208 

209 class Meta: 

210 verbose_name = _("board") 

211 verbose_name_plural = _("boards") 

212 ordering = ["-since"] 

213 

214 def save(self, **kwargs): 

215 self.active = True 

216 super().save(**kwargs) 

217 

218 def clean(self): 

219 if self.since is None: 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true

220 raise ValidationError( 

221 { 

222 "since": _("Please insert a starting year for the new board."), 

223 } 

224 ) 

225 

226 if self.until is None: 

227 raise ValidationError( 

228 { 

229 "until": _( 

230 "Please insert the year until when the board is active." 

231 ), 

232 } 

233 ) 

234 

235 def get_absolute_url(self): 

236 return reverse( 

237 "activemembers:board", args=[str(self.since.year), str(self.until.year)] 

238 ) 

239 

240 def validate_unique(self, **kwargs): 

241 super().validate_unique(**kwargs) 

242 boards = Board.objects.all() 

243 if self.since is not None: 

244 if overlaps(self, boards, can_equal=False): 

245 raise ValidationError( 

246 { 

247 "since": _("A board already exists for those years"), 

248 "until": _("A board already exists for those years"), 

249 } 

250 ) 

251 

252 

253class ActiveMembershipManager(models.Manager): 

254 """Custom manager that gets the currently active membergroup memberships.""" 

255 

256 def get_queryset(self): 

257 return super().get_queryset().exclude(until__lt=timezone.now().date()) 

258 

259 

260class MemberGroupMembership(models.Model): 

261 """Describes a group membership.""" 

262 

263 objects = models.Manager() 

264 active_objects = ActiveMembershipManager() 

265 

266 member = models.ForeignKey( 

267 "members.Member", 

268 on_delete=models.CASCADE, 

269 verbose_name=_("Member"), 

270 ) 

271 

272 group = models.ForeignKey( 

273 MemberGroup, 

274 on_delete=models.CASCADE, 

275 verbose_name=_("Group"), 

276 ) 

277 

278 since = models.DateField( 

279 verbose_name=_("Member since"), 

280 help_text=_("The date this member joined in this role"), 

281 default=datetime.date.today, 

282 ) 

283 

284 until = models.DateField( 

285 verbose_name=_("Member until"), 

286 help_text=_("A member until this time (can't be in the future)."), 

287 blank=True, 

288 null=True, 

289 ) 

290 

291 chair = models.BooleanField( 

292 verbose_name=_("Chair of the group"), 

293 help_text=_("There can only be one chair at a time!"), 

294 default=False, 

295 ) 

296 

297 has_chair_permissions = models.BooleanField( 

298 verbose_name=_("Person with chair permissions"), 

299 help_text=_("Give this member chair permission"), 

300 default=False, 

301 ) 

302 

303 role = models.CharField( 

304 _("role"), 

305 help_text=_("The role of this member"), 

306 max_length=255, 

307 blank=True, 

308 null=True, 

309 ) 

310 

311 @property 

312 def initial_connected_membership(self): 

313 """Find the oldest membership directly connected to the current one.""" 

314 qs = MemberGroupMembership.objects.filter( 

315 group=self.group, 

316 member=self.member, 

317 until__lte=self.since, 

318 until__gte=self.since - datetime.timedelta(days=1), 

319 ) 

320 if qs.exists(): # should only be one; should be unique 

321 return qs.first().initial_connected_membership 

322 return self 

323 

324 @property 

325 def latest_connected_membership(self): 

326 """Find the newest membership directly connected to the current one. 

327 

328 (thus the membership that started at the moment the current one ended). 

329 """ 

330 if self.until: 

331 qs = MemberGroupMembership.objects.filter( 

332 group=self.group, 

333 member=self.member, 

334 since__lte=self.until, 

335 since__gte=self.until + datetime.timedelta(days=1), 

336 ) 

337 if qs.exists(): # should only be one; should be unique 

338 return qs.last().latest_connected_membership 

339 return self 

340 

341 @property 

342 def is_active(self): 

343 """Is this membership currently active.""" 

344 return self.until is None or self.until > timezone.now().date() 

345 

346 def clean(self): 

347 try: 

348 if self.until and (not self.since or self.until < self.since): 

349 raise ValidationError( 

350 {"until": _("End date can't be before start date")} 

351 ) 

352 if self.until and self.group.until and self.until > self.group.until: 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true

353 raise ValidationError( 

354 {"until": _("End date can't be after the group end date")} 

355 ) 

356 if self.since and self.group.since and self.since < self.group.since: 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true

357 raise ValidationError( 

358 {"since": _("Start date can't be before group start date")} 

359 ) 

360 if self.since and self.group.until and self.since > self.group.until: 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true

361 raise ValidationError( 

362 {"since": _("Start date can't be after group end date")} 

363 ) 

364 except MemberGroupMembership.group.RelatedObjectDoesNotExist: 

365 pass 

366 

367 def validate_unique(self, **kwargs): 

368 try: 

369 super().validate_unique(**kwargs) 

370 # Skip checks if group hasn't been saved yet. 

371 if self.group.id is not None: 371 ↛ exitline 371 didn't return from function 'validate_unique' because the condition on line 371 was always true

372 # Check if a group has more than one chair 

373 if self.chair: 

374 chairs = MemberGroupMembership.objects.filter( 

375 group=self.group, chair=True 

376 ) 

377 if overlaps(self, chairs): 

378 raise ValidationError( 

379 { 

380 NON_FIELD_ERRORS: _( 

381 "There already is a chair for this time period" 

382 ) 

383 } 

384 ) 

385 

386 # check if this member is already in the group in this period 

387 memberships = MemberGroupMembership.objects.filter( 

388 group=self.group, member=self.member 

389 ) 

390 

391 if overlaps(self, memberships): 

392 raise ValidationError( 

393 { 

394 "member": _( 

395 "This member is already in the group for this period" 

396 ) 

397 } 

398 ) 

399 

400 except ( 

401 MemberGroupMembership.member.RelatedObjectDoesNotExist, 

402 MemberGroupMembership.group.RelatedObjectDoesNotExist, 

403 ): 

404 pass 

405 

406 def save(self, **kwargs): 

407 super().save(**kwargs) 

408 self.member.is_staff = self.member.membergroupmembership_set.exclude( 

409 until__lte=timezone.now().date() 

410 ).exists() 

411 self.member.save() 

412 

413 def __str__(self): 

414 return _("{member} membership of {group} since {since}, until {until}").format( 

415 member=self.member, group=self.group, since=self.since, until=self.until 

416 ) 

417 

418 class Meta: 

419 verbose_name = _("group membership") 

420 verbose_name_plural = _("group memberships") 

421 

422 

423class Mentorship(models.Model): 

424 """Describe a mentorship during the orientation.""" 

425 

426 member = models.ForeignKey( 

427 "members.Member", 

428 on_delete=models.CASCADE, 

429 verbose_name=_("Member"), 

430 ) 

431 year = models.IntegerField(validators=[MinValueValidator(1990)]) 

432 

433 def __str__(self): 

434 return _("{name} mentor in {year}").format(name=self.member, year=self.year) 

435 

436 class Meta: 

437 unique_together = ("member", "year")