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
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
1"""The models defined by the activemembers package."""
3import datetime
4import logging
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 _
17from thumbnails.fields import ImageField
18from tinymce.models import HTMLField
20from utils.media.services import get_upload_to_function
21from utils.snippets import overlaps
23logger = logging.getLogger(__name__)
26class ActiveMemberGroupManager(models.Manager):
27 """Returns active objects only sorted by the localized name."""
29 def get_queryset(self):
30 return super().get_queryset().exclude(active=False).order_by("name")
33class MemberGroup(models.Model):
34 """Describes a groups of members."""
36 objects = models.Manager()
37 active_objects = ActiveMemberGroupManager()
39 name = models.CharField(max_length=40, verbose_name=_("Name"), unique=True)
41 description = HTMLField(verbose_name=_("Description"))
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 )
52 members = models.ManyToManyField(
53 "members.Member", through="activemembers.MemberGroupMembership"
54 )
56 permissions = models.ManyToManyField(
57 Permission,
58 verbose_name=_("permissions"),
59 blank=True,
60 related_name="permissions_groups",
61 )
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 )
71 since = models.DateField(
72 _("founded in"),
73 null=True,
74 blank=True,
75 )
77 until = models.DateField(
78 _("existed until"),
79 null=True,
80 blank=True,
81 )
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 )
92 contact_email = models.EmailField(
93 _("contact email address"),
94 blank=True,
95 null=True,
96 )
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 )
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 )
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
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
125 def save(self, **kwargs):
126 super().save(**kwargs)
127 storage = self.photo.storage
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
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)
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 )
153 def __str__(self):
154 return str(self.name)
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 )
170 class Meta:
171 verbose_name = _("member group")
172 verbose_name_plural = _("member groups")
173 ordering = ["name"]
176class Committee(MemberGroup):
177 """Describes a committee, which is a type of MemberGroup."""
179 objects = models.Manager()
180 active_objects = ActiveMemberGroupManager()
182 def get_absolute_url(self):
183 return reverse("activemembers:committee", args=[str(self.pk)])
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
191class Society(MemberGroup):
192 """Describes a society, which is a type of MemberGroup."""
194 objects = models.Manager()
195 active_objects = ActiveMemberGroupManager()
197 def get_absolute_url(self):
198 return reverse("activemembers:society", args=[str(self.pk)])
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
206class Board(MemberGroup):
207 """Describes a board, which is a type of MemberGroup."""
209 class Meta:
210 verbose_name = _("board")
211 verbose_name_plural = _("boards")
212 ordering = ["-since"]
214 def save(self, **kwargs):
215 self.active = True
216 super().save(**kwargs)
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 )
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 )
235 def get_absolute_url(self):
236 return reverse(
237 "activemembers:board", args=[str(self.since.year), str(self.until.year)]
238 )
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 )
253class ActiveMembershipManager(models.Manager):
254 """Custom manager that gets the currently active membergroup memberships."""
256 def get_queryset(self):
257 return super().get_queryset().exclude(until__lt=timezone.now().date())
260class MemberGroupMembership(models.Model):
261 """Describes a group membership."""
263 objects = models.Manager()
264 active_objects = ActiveMembershipManager()
266 member = models.ForeignKey(
267 "members.Member",
268 on_delete=models.CASCADE,
269 verbose_name=_("Member"),
270 )
272 group = models.ForeignKey(
273 MemberGroup,
274 on_delete=models.CASCADE,
275 verbose_name=_("Group"),
276 )
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 )
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 )
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 )
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 )
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 )
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
324 @property
325 def latest_connected_membership(self):
326 """Find the newest membership directly connected to the current one.
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
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()
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
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 )
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 )
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 )
400 except (
401 MemberGroupMembership.member.RelatedObjectDoesNotExist,
402 MemberGroupMembership.group.RelatedObjectDoesNotExist,
403 ):
404 pass
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()
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 )
418 class Meta:
419 verbose_name = _("group membership")
420 verbose_name_plural = _("group memberships")
423class Mentorship(models.Model):
424 """Describe a mentorship during the orientation."""
426 member = models.ForeignKey(
427 "members.Member",
428 on_delete=models.CASCADE,
429 verbose_name=_("Member"),
430 )
431 year = models.IntegerField(validators=[MinValueValidator(1990)])
433 def __str__(self):
434 return _("{name} mentor in {year}").format(name=self.member, year=self.year)
436 class Meta:
437 unique_together = ("member", "year")