models.py 19.8 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
            )).exclude(active=False)

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


193 194 195 196
def _profile_image_path(instance, filename):
    return f'public/avatars/{instance.pk}'


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 226 227 228 229
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
230
        unique=True,
231 232 233 234 235 236 237 238 239
    )

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

240 241 242 243 244
    # ---- Address information -----

    address_street = models.CharField(
        max_length=100,
        validators=[validators.RegexValidator(
Thom Wiggers's avatar
Thom Wiggers committed
245
            regex=r'^.+ \d+.*',
Thom Wiggers's avatar
Thom Wiggers committed
246
            message=_('please use the format <street> <number>'),
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270
        )],
        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,
    )

271 272 273 274 275 276 277
    address_country = models.CharField(
        max_length=2,
        choices=countries.EUROPE,
        verbose_name=_('Country'),
        null=True,
    )

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

290
    # ---- Emergency contact ----
291

292 293 294 295
    emergency_contact = models.CharField(
        max_length=255,
        verbose_name=_('Emergency contact name'),
        help_text=_('Who should we contact in case of emergencies'),
296 297 298 299
        null=True,
        blank=True,
    )

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

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

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

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

327 328 329 330 331 332 333 334 335 336 337
    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'),
338
        blank=True,
339
        null=True,
340
        max_length=4096,
341 342 343 344 345 346 347
    )

    initials = models.CharField(
        max_length=20,
        verbose_name=_('Initials'),
        blank=True,
        null=True,
348 349 350 351 352 353 354 355 356
    )

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

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

369
    photo = models.ImageField(
370
        verbose_name=_('Photo'),
371
        upload_to=_profile_image_path,
372 373 374 375
        null=True,
        blank=True,
    )

376 377 378 379 380 381 382 383 384 385
    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',
    )

386 387 388 389
    # --- Communication preference ----

    language = models.CharField(
        verbose_name=_('Preferred language'),
390
        help_text=_('Preferred language for e.g. newsletters'),
391 392 393 394 395 396 397 398
        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"
399
                    " partners."),
400 401 402
        default=True,
    )

Sébastiaan Versteeg's avatar
Sébastiaan Versteeg committed
403 404 405 406 407 408
    receive_newsletter = models.BooleanField(
        verbose_name=_('Receive newsletter'),
        help_text=_("Receive the Thalia Newsletter"),
        default=True,
    )

409
    # --- Membership preference ----
410

411 412 413 414
    auto_renew = models.BooleanField(
        choices=((True, _('Yes, enable auto renewal.')),
                 (False, _('No, manual renewal required.'))),
        verbose_name=_('Automatically renew membership'),
415 416 417
        default=False,
    )

418 419
    def display_name(self):
        pref = self.display_name_preference
420
        if pref == 'nickname' and self.nickname is not None:
421
            return self.nickname
422
        elif pref == 'firstname':
423
            return self.user.first_name
424
        elif pref == 'initials':
425 426 427 428
            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:
429 430 431
            return "{} '{}' {}".format(self.user.first_name,
                                       self.nickname,
                                       self.user.last_name)
432
        elif pref == 'nicklast' and self.nickname is not None:
433 434 435
            return "'{}' {}".format(self.nickname,
                                    self.user.last_name)
        else:
436
            return self.user.get_full_name() or self.user.username
437
    display_name.short_description = _('Display name')
438

439 440
    def short_display_name(self):
        pref = self.display_name_preference
441 442
        if (self.nickname is not None and
                (pref == 'nickname' or pref == 'nicklast')):
443 444
            return self.nickname
        elif pref == 'initials':
445 446 447
            if self.initials:
                return '{} {}'.format(self.initials, self.user.last_name)
            return self.user.last_name
448 449 450
        else:
            return self.user.first_name

451
    def __init__(self, *args, **kwargs):
452
        super().__init__(*args, **kwargs)
453 454 455 456 457
        if self.photo:
            self._orig_image = self.photo.path
        else:
            self._orig_image = ""

458 459 460
    def clean(self):
        super().clean()
        errors = {}
461

462 463 464 465 466 467
        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')})
468 469 470 471 472 473 474

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

        if errors:
            raise ValidationError(errors)
475

476
    def save(self, *args, **kwargs):
Sébastiaan Versteeg's avatar
Sébastiaan Versteeg committed
477
        super().save(*args, **kwargs)
478 479 480 481 482 483 484 485 486

        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:
487 488
            image_path = self.photo.path
            image = Image.open(image_path)
Thom Wiggers's avatar
Thom Wiggers committed
489 490 491
            image_path, _ext = os.path.splitext(image_path)
            image_path = "{}.jpg".format(image_path)

492
            # Image.thumbnail does not upscale an image that is smaller
Thom Wiggers's avatar
Thom Wiggers committed
493
            logger.debug("Converting image %s", image_path)
494
            image.thumbnail(settings.PHOTO_UPLOAD_SIZE, Image.ANTIALIAS)
Thom Wiggers's avatar
Thom Wiggers committed
495 496 497 498
            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)
499 500

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

510
    def __str__(self):
511
        return _("Profile for {}").format(self.user)
512 513


514 515
class Membership(models.Model):

516
    MEMBER = 'member'
517
    BENEFACTOR = 'benefactor'
518 519
    HONORARY = 'honorary'

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

    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,
    )

550 551 552 553 554 555 556 557 558 559
    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

560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587
    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)

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


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')
    )

617 618 619 620 621 622 623 624 625 626
    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
        )

627 628 629 630 631 632 633 634 635 636
    @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.")})