models.py 19.7 KB
Newer Older
1
"""Models defined in the members package"""
2
import logging
3
import operator
4
import os
5 6
import uuid
from datetime import timedelta
7 8 9
from functools import reduce

from PIL import Image
10
from django.conf import settings
11
from django.contrib.auth.models import User, UserManager
12
from django.core import validators
13
from django.core.exceptions import ValidationError
14
from django.db import models
15
from django.db.models import Q
16
from django.urls import reverse
17
from django.utils import timezone
18
from django.utils.translation import pgettext_lazy, gettext_lazy as _
19

20
from activemembers.models import MemberGroup, MemberGroupMembership
21
from utils import countries
22

Thom Wiggers's avatar
Thom Wiggers committed
23 24 25
logger = logging.getLogger(__name__)


26 27 28 29 30 31 32 33
class MemberManager(UserManager):
    """Get all members, i.e. all users with a profile."""

    def get_queryset(self):
        return super().get_queryset().exclude(profile=None)


class ActiveMemberManager(MemberManager):
Thom Wiggers's avatar
Thom Wiggers committed
34
    """Get all active members, i.e. who have a committee membership"""
35

36
    def get_queryset(self):
Thom Wiggers's avatar
Thom Wiggers committed
37
        """Select all committee members"""
38
        active_memberships = (MemberGroupMembership
39 40 41
                              .active_objects
                              .filter(group__board=None)
                              .filter(group__society=None))
42 43

        return (super().get_queryset()
44
                .filter(membergroupmembership__in=active_memberships)
45 46 47 48 49 50
                .distinct())


class CurrentMemberManager(MemberManager):
    """Get all members with an active membership"""

51
    def get_queryset(self):
Thom Wiggers's avatar
Thom Wiggers committed
52 53 54
        """
        Select all members who have a current membership
        """
55
        return (super().get_queryset()
56 57 58
                .exclude(membership=None)
                .filter(Q(membership__until__isnull=True) |
                        Q(membership__until__gt=timezone.now().date()))
59
                .distinct())
60

61
    def with_birthdays_in_range(self, from_date, to_date):
Thom Wiggers's avatar
Thom Wiggers committed
62 63 64 65 66 67 68 69 70 71 72 73
        """
        Select all who are currently a Thalia member and have a
        birthday within the specified range

        :param from_date: the start of the range (inclusive)
        :param to_date: the end of the range (inclusive)
        :paramtype from_date: datetime
        :paramtype to_date: datetime

        :return: the filtered queryset
        :rtype: Queryset
        """
74 75
        queryset = (self.get_queryset()
                        .filter(profile__birthday__lte=to_date))
76 77 78 79 80 81 82 83 84

        if (to_date - from_date).days >= 366:
            # 366 is important to also account for leap years
            # Everyone that's born before to_date has a birthday
            return queryset

        delta = to_date - from_date
        dates = [from_date + timedelta(days=i) for i in range(delta.days + 1)]
        monthdays = [
85 86
            {"profile__birthday__month": d.month,
             "profile__birthday__day": d.day}
87 88 89 90 91 92 93
            for d in dates
        ]
        # Don't get me started (basically, we are making a giant OR query with
        # all days and months that are in the range)
        query = reduce(operator.or_, [Q(**d) for d in monthdays])
        return queryset.filter(query)

94

95 96 97 98
class Member(User):
    class Meta:
        proxy = True
        ordering = ('first_name', 'last_name')
99
        permissions = (
100
            ('nextcloud_admin', _("Access NextCloud as admin")),
101
        )
102

103
    objects = MemberManager()
104
    current_members = CurrentMemberManager()
105 106
    active_members = ActiveMemberManager()

107 108
    def __str__(self):
        return '{} ({})'.format(self.get_full_name(), self.username)
109

110 111
    @property
    def current_membership(self):
Thom Wiggers's avatar
Thom Wiggers committed
112 113 114 115 116 117
        """
        The currently active membership of the user. None if not active.

        :return: the currently active membership or None
        :rtype: Membership or None
        """
118 119 120 121
        membership = self.latest_membership
        if membership and not membership.is_active():
            return None
        return membership
122

123 124
    @property
    def latest_membership(self):
Thom Wiggers's avatar
Thom Wiggers committed
125
        """Get the most recent membership of this user"""
126 127 128 129
        if not self.membership_set.exists():
            return None
        return self.membership_set.latest('since')

130 131
    @property
    def earliest_membership(self):
Thom Wiggers's avatar
Thom Wiggers committed
132
        """Get the earliest membership of this user"""
133 134 135 136
        if not self.membership_set.exists():
            return None
        return self.membership_set.earliest('since')

137
    def has_been_member(self):
Thom Wiggers's avatar
Thom Wiggers committed
138
        """Has this user ever been a member?"""
139 140 141
        return self.membership_set.filter(type='member').count() > 0

    def has_been_honorary_member(self):
Thom Wiggers's avatar
Thom Wiggers committed
142
        """Has this user ever been an honorary member?"""
143 144
        return self.membership_set.filter(type='honorary').count() > 0

145
    def has_active_membership(self):
146 147 148 149 150
        """Is this member currently active

        Tested by checking if the expiration date has passed.
        """
        return self.current_membership is not None
151

152
    # Special properties for admin site
153 154 155
    has_active_membership.boolean = True
    has_active_membership.short_description = \
        _('Is this user currently active')
156

157
    @classmethod
158
    def all_with_membership(cls, membership_type):
Thom Wiggers's avatar
Thom Wiggers committed
159 160 161 162 163 164 165
        """
        Get all users who have a specific membership.

        :param membership_type: The membership to select by
        :return: List of users
        :rtype: [Member]
        """
166
        return [x for x in cls.objects.all()
167 168
                if x.current_membership and
                x.current_membership.type == membership_type]
169

170 171
    @property
    def can_attend_events(self):
Thom Wiggers's avatar
Thom Wiggers committed
172
        """May this user attend events"""
173 174 175 176 177 178 179
        if not self.profile:
            return False

        return ((self.profile.event_permissions == 'all' or
                self.profile.event_permissions == 'no_drinks') and
                self.current_membership is not None)

180 181 182
    def get_member_groups(self):
        """Get the groups this user is a member of"""
        return MemberGroup.objects.filter(
183
            Q(membergroupmembership__member=self) &
184
            (
185 186
                Q(membergroupmembership__until=None) |
                Q(membergroupmembership__until__gt=timezone.now())
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225
            )).exclude(active=False)

    def get_absolute_url(self):
        return reverse('members:profile', args=[str(self.pk)])


class Profile(models.Model):
    """This class holds extra information about a member"""

    # No longer yearly membership as a type, use expiration date instead.
    PROGRAMME_CHOICES = (
        ('computingscience', _('Computing Science')),
        ('informationscience', _('Information Sciences')))

    # Preferably this would have been a foreign key to Member instead,
    # but the UserAdmin requires that this is a foreign key to User.
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

    # ----- Registration information -----

    programme = models.CharField(
        max_length=20,
        choices=PROGRAMME_CHOICES,
        verbose_name=_('Study programme'),
        blank=True,
        null=True,
    )

    student_number = models.CharField(
        verbose_name=_('Student number'),
        max_length=8,
        validators=[validators.RegexValidator(
            regex=r'(s\d{7}|[ezu]\d{6,7})',
            message=_('Enter a valid student- or e/z/u-number.'))],
        blank=True,
        null=True,
Thom Wiggers's avatar
Thom Wiggers committed
226
        unique=True,
227 228 229 230 231 232 233 234 235
    )

    starting_year = models.IntegerField(
        verbose_name=_('Starting year'),
        help_text=_('The year this member started studying.'),
        blank=True,
        null=True,
    )

236 237 238 239 240
    # ---- Address information -----

    address_street = models.CharField(
        max_length=100,
        validators=[validators.RegexValidator(
Thom Wiggers's avatar
Thom Wiggers committed
241
            regex=r'^.+ \d+.*',
Thom Wiggers's avatar
Thom Wiggers committed
242
            message=_('please use the format <street> <number>'),
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266
        )],
        verbose_name=_('Street and house number'),
        null=True,
    )

    address_street2 = models.CharField(
        max_length=100,
        verbose_name=_('Second address line'),
        blank=True,
        null=True,
    )

    address_postal_code = models.CharField(
        max_length=10,
        verbose_name=_('Postal code'),
        null=True,
    )

    address_city = models.CharField(
        max_length=40,
        verbose_name=_('City'),
        null=True,
    )

267 268 269 270 271 272 273
    address_country = models.CharField(
        max_length=2,
        choices=countries.EUROPE,
        verbose_name=_('Country'),
        null=True,
    )

274
    phone_number = models.CharField(
Thom Wiggers's avatar
Thom Wiggers committed
275
        max_length=20,
276 277 278 279
        verbose_name=_('Phone number'),
        help_text=_('Enter a phone number so Thalia may reach you'),
        validators=[validators.RegexValidator(
            regex=r'^\+?\d+$',
280
            message=_('Please enter a valid phone number'),
281 282 283 284 285
        )],
        null=True,
        blank=True,
    )

286
    # ---- Emergency contact ----
287

288 289 290 291
    emergency_contact = models.CharField(
        max_length=255,
        verbose_name=_('Emergency contact name'),
        help_text=_('Who should we contact in case of emergencies'),
292 293 294 295
        null=True,
        blank=True,
    )

296
    emergency_contact_phone_number = models.CharField(
Thom Wiggers's avatar
Thom Wiggers committed
297
        max_length=20,
298 299
        verbose_name=_('Emergency contact phone number'),
        help_text=_('The phone number for the emergency contact'),
300 301
        validators=[validators.RegexValidator(
            regex=r'^\+?\d+$',
302
            message=_('Please enter a valid phone number'),
303 304 305 306 307 308 309 310 311 312 313 314
        )],
        null=True,
        blank=True,
    )

    # ---- Personal information ------

    birthday = models.DateField(
        verbose_name=_('Birthday'),
        null=True
    )

315 316 317
    show_birthday = models.BooleanField(
        verbose_name=_('Display birthday'),
        help_text=_(
318
            'Show your birthday to other members on your profile page and '
319 320 321 322
            'in the birthday calendar'),
        default=True,
    )

323 324 325 326 327 328 329 330 331 332 333
    website = models.URLField(
        max_length=200,
        verbose_name=_('Website'),
        help_text=_('Website to display on your profile page'),
        blank=True,
        null=True
    )

    profile_description = models.TextField(
        verbose_name=_('Profile text'),
        help_text=_('Text to display on your profile'),
334
        blank=True,
335
        null=True,
336
        max_length=4096,
337 338 339 340 341 342 343
    )

    initials = models.CharField(
        max_length=20,
        verbose_name=_('Initials'),
        blank=True,
        null=True,
344 345 346 347 348 349 350 351 352
    )

    nickname = models.CharField(
        max_length=30,
        verbose_name=_('Nickname'),
        blank=True,
        null=True,
    )

353 354 355 356 357
    display_name_preference = models.CharField(
        max_length=10,
        verbose_name=_('How to display name'),
        choices=(('full', _('Show full name')),
                 ('nickname', _('Show only nickname')),
358
                 ('firstname', _('Show only first name')),
359 360 361 362 363 364
                 ('initials', _('Show initials and last name')),
                 ('fullnick', _("Show name like \"John 'nickname' Doe\"")),
                 ('nicklast', _("Show nickname and last name"))),
        default='full',
    )

365
    photo = models.ImageField(
366
        verbose_name=_('Photo'),
367 368 369 370 371
        upload_to='public/avatars/',
        null=True,
        blank=True,
    )

372 373 374 375 376 377 378 379 380 381
    event_permissions = models.CharField(
        max_length=9,
        verbose_name=_('Which events can this member attend'),
        choices=(('all', _('All events')),
                 ('no_events', _('User may not attend events')),
                 ('no_drinks', _('User may not attend drinks')),
                 ('nothing', _('User may not attend anything'))),
        default='all',
    )

382 383 384 385
    # --- Communication preference ----

    language = models.CharField(
        verbose_name=_('Preferred language'),
386
        help_text=_('Preferred language for e.g. newsletters'),
387 388 389 390 391 392 393 394
        max_length=5,
        choices=settings.LANGUAGES,
        default='nl',
    )

    receive_optin = models.BooleanField(
        verbose_name=_('Receive opt-in mailings'),
        help_text=_("Receive mailings about vacancies and events from Thalia's"
395
                    " partners."),
396 397 398
        default=True,
    )

Sébastiaan Versteeg's avatar
Sébastiaan Versteeg committed
399 400 401 402 403 404
    receive_newsletter = models.BooleanField(
        verbose_name=_('Receive newsletter'),
        help_text=_("Receive the Thalia Newsletter"),
        default=True,
    )

405
    # --- Membership preference ----
406

407 408 409 410
    auto_renew = models.BooleanField(
        choices=((True, _('Yes, enable auto renewal.')),
                 (False, _('No, manual renewal required.'))),
        verbose_name=_('Automatically renew membership'),
411 412 413
        default=False,
    )

414 415
    def display_name(self):
        pref = self.display_name_preference
416
        if pref == 'nickname' and self.nickname is not None:
Thom Wiggers's avatar
Thom Wiggers committed
417
            return f"'{self.nickname}'"
418
        elif pref == 'firstname':
419
            return self.user.first_name
420
        elif pref == 'initials':
421 422 423 424
            if self.initials:
                return '{} {}'.format(self.initials, self.user.last_name)
            return self.user.last_name
        elif pref == 'fullnick' and self.nickname is not None:
425 426 427
            return "{} '{}' {}".format(self.user.first_name,
                                       self.nickname,
                                       self.user.last_name)
428
        elif pref == 'nicklast' and self.nickname is not None:
429 430 431
            return "'{}' {}".format(self.nickname,
                                    self.user.last_name)
        else:
432
            return self.user.get_full_name() or self.user.username
433
    display_name.short_description = _('Display name')
434

435 436
    def short_display_name(self):
        pref = self.display_name_preference
437 438
        if (self.nickname is not None and
                (pref == 'nickname' or pref == 'nicklast')):
Thom Wiggers's avatar
Thom Wiggers committed
439
            return f"'{self.nickname}'"
440
        elif pref == 'initials':
441 442 443
            if self.initials:
                return '{} {}'.format(self.initials, self.user.last_name)
            return self.user.last_name
444 445 446
        else:
            return self.user.first_name

447
    def __init__(self, *args, **kwargs):
448
        super().__init__(*args, **kwargs)
449 450 451 452 453
        if self.photo:
            self._orig_image = self.photo.path
        else:
            self._orig_image = ""

454 455 456
    def clean(self):
        super().clean()
        errors = {}
457

458 459 460 461 462 463
        if self.display_name_preference in ('nickname', 'fullnick',
                                            'nicklast'):
            if not self.nickname:
                errors.update(
                    {'nickname': _('You need to enter a nickname to use it as '
                                   'display name')})
464 465 466 467 468 469 470

        if self.birthday and self.birthday > timezone.now().date():
            errors.update(
                {'birthday': _('A birthday cannot be in the future.')})

        if errors:
            raise ValidationError(errors)
471

472
    def save(self, *args, **kwargs):
Sébastiaan Versteeg's avatar
Sébastiaan Versteeg committed
473
        super().save(*args, **kwargs)
474 475 476 477 478 479 480 481 482

        if self._orig_image and not self.photo:
            try:
                os.remove(self._orig_image)
            except FileNotFoundError:
                pass
            self._orig_image = ''

        elif self.photo and self._orig_image != self.photo.path:
483 484
            image_path = self.photo.path
            image = Image.open(image_path)
Thom Wiggers's avatar
Thom Wiggers committed
485 486 487
            image_path, _ext = os.path.splitext(image_path)
            image_path = "{}.jpg".format(image_path)

488
            # Image.thumbnail does not upscale an image that is smaller
Thom Wiggers's avatar
Thom Wiggers committed
489
            logger.debug("Converting image %s", image_path)
490
            image.thumbnail(settings.PHOTO_UPLOAD_SIZE, Image.ANTIALIAS)
Thom Wiggers's avatar
Thom Wiggers committed
491 492 493 494
            image.convert("RGB").save(image_path, "JPEG")
            image_name, _ext = os.path.splitext(self.photo.name)
            self.photo.name = "{}.jpg".format(image_name)
            super().save(*args, **kwargs)
495 496

            try:
Thom Wiggers's avatar
Thom Wiggers committed
497 498 499
                if self._orig_image:
                    logger.info("deleting", self._orig_image)
                    os.remove(self._orig_image)
500 501
            except FileNotFoundError:
                pass
502
            self._orig_image = self.photo.path
Thom Wiggers's avatar
Thom Wiggers committed
503 504
        else:
            logging.warning("We already had this image")
505

506
    def __str__(self):
507
        return _("Profile for {}").format(self.user)
508 509


510 511
class Membership(models.Model):

512
    MEMBER = 'member'
513
    BENEFACTOR = 'benefactor'
514 515
    HONORARY = 'honorary'

516
    MEMBERSHIP_TYPES = (
517
        (MEMBER, _('Member')),
518
        (BENEFACTOR, _('Benefactor')),
519
        (HONORARY, _('Honorary Member')))
520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545

    type = models.CharField(
        max_length=40,
        choices=MEMBERSHIP_TYPES,
        verbose_name=_('Membership type'),
    )

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        verbose_name=_('User'),
    )

    since = models.DateField(
        verbose_name=_("Membership since"),
        help_text=_("The date the member started holding this membership."),
        default=timezone.now
    )

    until = models.DateField(
        verbose_name=_("Membership until"),
        help_text=_("The date the member stops holding this membership."),
        blank=True,
        null=True,
    )

546 547 548 549 550 551 552 553 554 555
    def __str__(self):
        s = _("Membership of type {} for {} ({}) starting {}").format(
                self.get_type_display(), self.user.get_full_name(),
                self.user.username, self.since,
            )
        if self.until is not None:
            s += pgettext_lazy("Membership until x", " until {}").format(
                    self.until)
        return s

556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583
    def clean(self):
        super().clean()

        errors = {}
        if self.until and (not self.since or self.until < self.since):
            raise ValidationError(
                {'until': _("End date can't be before start date")})

        if self.since is not None:
            memberships = self.user.membership_set.all()
            for membership in memberships:
                if membership.pk == self.pk:
                    continue
                if ((membership.until is None and (
                    self.until is None or self.until > membership.since)) or
                    (self.until is None and self.since < membership.until) or
                    (self.until and membership.until and
                     self.since < membership.until and
                     self.until > membership.since)):
                    errors.update({
                        'since': _('A membership already '
                                   'exists for that period'),
                        'until': _('A membership already '
                                   'exists for that period')})

        if errors:
            raise ValidationError(errors)

584
    def is_active(self):
585
        return not self.until or self.until > timezone.now().date()
586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612


class EmailChange(models.Model):
    created_at = models.DateTimeField(_('created at'), default=timezone.now)

    member = models.ForeignKey(
        'members.Member',
        on_delete=models.CASCADE,
        verbose_name=_('member'),
    )

    email = models.EmailField(_('email'), max_length=254)

    verify_key = models.UUIDField(unique=True, default=uuid.uuid4,
                                  editable=False)
    confirm_key = models.UUIDField(unique=True, default=uuid.uuid4,
                                   editable=False)

    verified = models.BooleanField(
        _('verified'), default=False,
        help_text=_('the new email address is valid')
    )
    confirmed = models.BooleanField(
        _('confirmed'), default=False,
        help_text=_('the old email address was checked')
    )

613 614 615 616 617 618 619 620 621 622
    def __str__(self):
        return _(
            "Email change request for {} to {} "
            "created at {} "
            "(confirmed: {}, verified: {})."
        ).format(
            self.member, self.email, self.created_at, self.confirmed,
            self.verified
        )

623 624 625 626 627 628 629 630 631 632
    @property
    def completed(self):
        return self.verified and self.confirmed

    def clean(self):
        super().clean()

        if self.email == self.member.email:
            raise ValidationError(
                {'email': _("Please enter a new email address.")})