mirror of
				https://gitlab.crans.org/bde/nk20
				synced 2025-11-04 09:12:11 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			522 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			522 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
 | 
						|
# SPDX-License-Identifier: GPL-3.0-or-later
 | 
						|
 | 
						|
import datetime
 | 
						|
import os
 | 
						|
 | 
						|
from django.conf import settings
 | 
						|
from django.contrib.auth.models import User
 | 
						|
from django.core.exceptions import ValidationError
 | 
						|
from django.db import models, transaction
 | 
						|
from django.db.models import Q
 | 
						|
from django.template import loader
 | 
						|
from django.urls import reverse, reverse_lazy
 | 
						|
from django.utils import timezone
 | 
						|
from django.utils.encoding import force_bytes
 | 
						|
from django.utils.http import urlsafe_base64_encode
 | 
						|
from django.utils.translation import gettext_lazy as _
 | 
						|
from phonenumber_field.modelfields import PhoneNumberField
 | 
						|
from permission.models import Role
 | 
						|
from registration.tokens import email_validation_token
 | 
						|
from note.models import MembershipTransaction
 | 
						|
 | 
						|
 | 
						|
class Profile(models.Model):
 | 
						|
    """
 | 
						|
    An user profile
 | 
						|
 | 
						|
    We do not want to patch the Django Contrib :model:`auth.User`model;
 | 
						|
    so this model add an user profile with additional information.
 | 
						|
    """
 | 
						|
    user = models.OneToOneField(
 | 
						|
        settings.AUTH_USER_MODEL,
 | 
						|
        on_delete=models.CASCADE,
 | 
						|
    )
 | 
						|
 | 
						|
    phone_number = PhoneNumberField(
 | 
						|
        verbose_name=_('phone number'),
 | 
						|
        max_length=50,
 | 
						|
        blank=True,
 | 
						|
        null=True,
 | 
						|
    )
 | 
						|
 | 
						|
    section = models.CharField(
 | 
						|
        verbose_name=_('section'),
 | 
						|
        help_text=_('e.g. "1A0", "9A♥", "SAPHIRE"'),
 | 
						|
        max_length=255,
 | 
						|
        blank=True,
 | 
						|
        default="",
 | 
						|
    )
 | 
						|
 | 
						|
    department = models.CharField(
 | 
						|
        max_length=8,
 | 
						|
        verbose_name=_("department"),
 | 
						|
        choices=[
 | 
						|
            ('A0', _("Informatics (A0)")),
 | 
						|
            ('A1', _("Mathematics (A1)")),
 | 
						|
            ('A2', _("Physics (A2)")),
 | 
						|
            ("A'2", _("Applied physics (A'2)")),
 | 
						|
            ("A''2", _("Chemistry (A''2)")),
 | 
						|
            ('A3', _("Biology (A3)")),
 | 
						|
            ('B1234', _("SAPHIRE (B1234)")),
 | 
						|
            ('B1', _("Mechanics (B1)")),
 | 
						|
            ('B2', _("Civil engineering (B2)")),
 | 
						|
            ('B3', _("Mechanical engineering (B3)")),
 | 
						|
            ('B4', _("EEA (B4)")),
 | 
						|
            ('C', _("Design (C)")),
 | 
						|
            ('D2', _("Economy-management (D2)")),
 | 
						|
            ('D3', _("Social sciences (D3)")),
 | 
						|
            ('E', _("English (E)")),
 | 
						|
            ('EXT', _("External (EXT)")),
 | 
						|
        ]
 | 
						|
    )
 | 
						|
 | 
						|
    promotion = models.PositiveSmallIntegerField(
 | 
						|
        null=True,
 | 
						|
        default=datetime.date.today().year if datetime.date.today().month >= 8 else datetime.date.today().year - 1,
 | 
						|
        verbose_name=_("promotion"),
 | 
						|
        help_text=_("Year of entry to the school (None if not ENS student)"),
 | 
						|
    )
 | 
						|
 | 
						|
    address = models.CharField(
 | 
						|
        verbose_name=_('address'),
 | 
						|
        max_length=255,
 | 
						|
        blank=True,
 | 
						|
        default="",
 | 
						|
    )
 | 
						|
 | 
						|
    paid = models.BooleanField(
 | 
						|
        verbose_name=_("paid"),
 | 
						|
        help_text=_("Tells if the user receive a salary."),
 | 
						|
        default=False,
 | 
						|
    )
 | 
						|
 | 
						|
    ml_events_registration = models.CharField(
 | 
						|
        blank=True,
 | 
						|
        default='',
 | 
						|
        max_length=2,
 | 
						|
        choices=[
 | 
						|
            ('', _("No")),
 | 
						|
            ('fr', _("Yes (receive them in french)")),
 | 
						|
            ('en', _("Yes (receive them in english)")),
 | 
						|
        ],
 | 
						|
        verbose_name=_("Register on the mailing list to stay informed of the events of the campus (1 mail/week)"),
 | 
						|
    )
 | 
						|
 | 
						|
    ml_sport_registration = models.BooleanField(
 | 
						|
        default=False,
 | 
						|
        verbose_name=_("Register on the mailing list to stay informed of the sport events of the campus (1 mail/week)"),
 | 
						|
    )
 | 
						|
 | 
						|
    ml_art_registration = models.BooleanField(
 | 
						|
        default=False,
 | 
						|
        verbose_name=_("Register on the mailing list to stay informed of the art events of the campus (1 mail/week)"),
 | 
						|
    )
 | 
						|
 | 
						|
    report_frequency = models.PositiveSmallIntegerField(
 | 
						|
        verbose_name=_("report frequency (in days)"),
 | 
						|
        default=0,
 | 
						|
    )
 | 
						|
 | 
						|
    last_report = models.DateTimeField(
 | 
						|
        verbose_name=_("last report date"),
 | 
						|
        default=timezone.now,
 | 
						|
    )
 | 
						|
 | 
						|
    email_confirmed = models.BooleanField(
 | 
						|
        verbose_name=_("email confirmed"),
 | 
						|
        default=False,
 | 
						|
    )
 | 
						|
 | 
						|
    registration_valid = models.BooleanField(
 | 
						|
        verbose_name=_("registration valid"),
 | 
						|
        default=False,
 | 
						|
    )
 | 
						|
 | 
						|
    VSS_charter_read = models.BooleanField(
 | 
						|
        verbose_name=_("VSS charter read"),
 | 
						|
        default=False
 | 
						|
    )
 | 
						|
 | 
						|
    class Meta:
 | 
						|
        verbose_name = _('user profile')
 | 
						|
        verbose_name_plural = _('user profile')
 | 
						|
        indexes = [models.Index(fields=['user'])]
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return str(self.user)
 | 
						|
 | 
						|
    def get_absolute_url(self):
 | 
						|
        return reverse('member:user_detail', args=(self.user_id,))
 | 
						|
 | 
						|
    @property
 | 
						|
    def ens_year(self):
 | 
						|
        """
 | 
						|
        Number of years since the 1st august of the entry year, rounded up.
 | 
						|
        """
 | 
						|
        if self.promotion is None:
 | 
						|
            return 0
 | 
						|
        today = datetime.date.today()
 | 
						|
        years = today.year - self.promotion
 | 
						|
        if today.month >= 8:
 | 
						|
            years += 1
 | 
						|
        return years
 | 
						|
 | 
						|
    @property
 | 
						|
    def section_generated(self):
 | 
						|
        return str(self.ens_year) + self.department
 | 
						|
 | 
						|
    @property
 | 
						|
    def soge(self):
 | 
						|
        if "treasury" in settings.INSTALLED_APPS:
 | 
						|
            from treasury.models import SogeCredit
 | 
						|
            return SogeCredit.objects.filter(user=self.user, credit_transaction__isnull=False).exists()
 | 
						|
        return False
 | 
						|
 | 
						|
    def send_email_validation_link(self):
 | 
						|
        subject = "[Note Kfet] " + str(_("Activate your Note Kfet account"))
 | 
						|
        token = email_validation_token.make_token(self.user)
 | 
						|
        uid = urlsafe_base64_encode(force_bytes(self.user_id))
 | 
						|
        message = loader.render_to_string('registration/mails/email_validation_email.txt',
 | 
						|
                                          {
 | 
						|
                                              'user': self.user,
 | 
						|
                                              'domain': os.getenv("NOTE_URL", "note.example.com"),
 | 
						|
                                              'token': token,
 | 
						|
                                              'uid': uid,
 | 
						|
                                          })
 | 
						|
        html = loader.render_to_string('registration/mails/email_validation_email.html',
 | 
						|
                                       {
 | 
						|
                                           'user': self.user,
 | 
						|
                                           'domain': os.getenv("NOTE_URL", "note.example.com"),
 | 
						|
                                           'token': token,
 | 
						|
                                           'uid': uid,
 | 
						|
                                       })
 | 
						|
        self.user.email_user(subject, message, html_message=html)
 | 
						|
 | 
						|
 | 
						|
class Club(models.Model):
 | 
						|
    """
 | 
						|
    A club is a group of people, whose membership is handle by their
 | 
						|
    :model:`member.Membership`, and gives access to right defined by a :model:`member.Role`.
 | 
						|
    """
 | 
						|
    name = models.CharField(
 | 
						|
        verbose_name=_('name'),
 | 
						|
        max_length=255,
 | 
						|
        unique=True,
 | 
						|
    )
 | 
						|
 | 
						|
    email = models.EmailField(
 | 
						|
        verbose_name=_('email'),
 | 
						|
    )
 | 
						|
 | 
						|
    parent_club = models.ForeignKey(
 | 
						|
        'self',
 | 
						|
        null=True,
 | 
						|
        blank=True,
 | 
						|
        on_delete=models.PROTECT,
 | 
						|
        verbose_name=_('parent club'),
 | 
						|
    )
 | 
						|
 | 
						|
    # Memberships
 | 
						|
 | 
						|
    # When set to False, the membership system won't be used.
 | 
						|
    # Useful to create notes for activities or departments.
 | 
						|
    require_memberships = models.BooleanField(
 | 
						|
        default=True,
 | 
						|
        verbose_name=_("require memberships"),
 | 
						|
        help_text=_("Uncheck if this club don't require memberships."),
 | 
						|
    )
 | 
						|
 | 
						|
    membership_fee_paid = models.PositiveIntegerField(
 | 
						|
        default=0,
 | 
						|
        verbose_name=_('membership fee (paid students)'),
 | 
						|
    )
 | 
						|
 | 
						|
    membership_fee_unpaid = models.PositiveIntegerField(
 | 
						|
        default=0,
 | 
						|
        verbose_name=_('membership fee (unpaid students)'),
 | 
						|
    )
 | 
						|
 | 
						|
    membership_duration = models.PositiveIntegerField(
 | 
						|
        blank=True,
 | 
						|
        null=True,
 | 
						|
        verbose_name=_('membership duration'),
 | 
						|
        help_text=_('The longest time (in days) a membership can last '
 | 
						|
                    '(NULL = infinite).'),
 | 
						|
    )
 | 
						|
 | 
						|
    membership_start = models.DateField(
 | 
						|
        blank=True,
 | 
						|
        null=True,
 | 
						|
        verbose_name=_('membership start'),
 | 
						|
        help_text=_('Date from which the members can renew their membership.'),
 | 
						|
    )
 | 
						|
 | 
						|
    membership_end = models.DateField(
 | 
						|
        blank=True,
 | 
						|
        null=True,
 | 
						|
        verbose_name=_('membership end'),
 | 
						|
        help_text=_('Maximal date of a membership, after which members must renew it.'),
 | 
						|
    )
 | 
						|
 | 
						|
    add_registration_form = models.BooleanField(
 | 
						|
        verbose_name=_("add to registration form"),
 | 
						|
        default=False,
 | 
						|
    )
 | 
						|
 | 
						|
    class Meta:
 | 
						|
        verbose_name = _("club")
 | 
						|
        verbose_name_plural = _("clubs")
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return self.name
 | 
						|
 | 
						|
    @transaction.atomic
 | 
						|
    def save(self, force_insert=False, force_update=False, using=None,
 | 
						|
             update_fields=None):
 | 
						|
        if not self.require_memberships:
 | 
						|
            self.membership_fee_paid = 0
 | 
						|
            self.membership_fee_unpaid = 0
 | 
						|
            self.membership_duration = None
 | 
						|
            self.membership_start = None
 | 
						|
            self.membership_end = None
 | 
						|
        super().save(force_insert, force_update, update_fields)
 | 
						|
 | 
						|
    def get_absolute_url(self):
 | 
						|
        return reverse_lazy('member:club_detail', args=(self.pk,))
 | 
						|
 | 
						|
    def update_membership_dates(self):
 | 
						|
        """
 | 
						|
        This function is called each time the club detail view is displayed.
 | 
						|
        Update the year of the membership dates.
 | 
						|
        """
 | 
						|
        if not self.membership_start or not self.membership_end:
 | 
						|
            return
 | 
						|
 | 
						|
        today = datetime.date.today()
 | 
						|
 | 
						|
        # Avoid any problems on February 29
 | 
						|
        if self.membership_start.month == 2 and self.membership_start.day == 29:
 | 
						|
            self.membership_start -= datetime.timedelta(days=1)
 | 
						|
        if self.membership_end.month == 2 and self.membership_end.day == 29:
 | 
						|
            self.membership_end += datetime.timedelta(days=1)
 | 
						|
 | 
						|
        while today >= datetime.date(self.membership_start.year + 1,
 | 
						|
                                     self.membership_start.month, self.membership_start.day):
 | 
						|
            if self.membership_start:
 | 
						|
                self.membership_start = datetime.date(self.membership_start.year + 1,
 | 
						|
                                                      self.membership_start.month, self.membership_start.day)
 | 
						|
            if self.membership_end:
 | 
						|
                self.membership_end = datetime.date(self.membership_end.year + 1,
 | 
						|
                                                    self.membership_end.month, self.membership_end.day)
 | 
						|
            self._force_save = True
 | 
						|
            self.save(force_update=True)
 | 
						|
 | 
						|
 | 
						|
class Membership(models.Model):
 | 
						|
    """
 | 
						|
    Register the membership of a user to a club, including roles and membership duration.
 | 
						|
 | 
						|
    """
 | 
						|
    user = models.ForeignKey(
 | 
						|
        User,
 | 
						|
        on_delete=models.PROTECT,
 | 
						|
        related_name="memberships",
 | 
						|
        verbose_name=_("user"),
 | 
						|
    )
 | 
						|
 | 
						|
    club = models.ForeignKey(
 | 
						|
        Club,
 | 
						|
        on_delete=models.PROTECT,
 | 
						|
        verbose_name=_("club"),
 | 
						|
    )
 | 
						|
 | 
						|
    roles = models.ManyToManyField(
 | 
						|
        "permission.Role",
 | 
						|
        related_name="memberships",
 | 
						|
        verbose_name=_("roles"),
 | 
						|
    )
 | 
						|
 | 
						|
    date_start = models.DateField(
 | 
						|
        default=datetime.date.today,
 | 
						|
        verbose_name=_('membership starts on'),
 | 
						|
    )
 | 
						|
 | 
						|
    date_end = models.DateField(
 | 
						|
        verbose_name=_('membership ends on'),
 | 
						|
        null=True,
 | 
						|
    )
 | 
						|
 | 
						|
    fee = models.PositiveIntegerField(
 | 
						|
        verbose_name=_('fee'),
 | 
						|
    )
 | 
						|
 | 
						|
    class Meta:
 | 
						|
        verbose_name = _('membership')
 | 
						|
        verbose_name_plural = _('memberships')
 | 
						|
        indexes = [models.Index(fields=['user'])]
 | 
						|
 | 
						|
    def __str__(self):
 | 
						|
        return _("Membership of {user} for the club {club}").format(user=self.user.username, club=self.club.name, )
 | 
						|
 | 
						|
    @transaction.atomic
 | 
						|
    def save(self, *args, **kwargs):
 | 
						|
        """
 | 
						|
        Calculate fee and end date before saving the membership and creating the transaction if needed.
 | 
						|
        """
 | 
						|
        # Ensure that club membership dates are valid
 | 
						|
        old_membership_start = self.club.membership_start
 | 
						|
        self.club.update_membership_dates()
 | 
						|
        if self.club.membership_start != old_membership_start:
 | 
						|
            self.club.save()
 | 
						|
 | 
						|
        created = not self.pk
 | 
						|
        if not created:
 | 
						|
            for role in self.roles.all():
 | 
						|
                club = role.for_club
 | 
						|
                if club is not None:
 | 
						|
                    if club.pk != self.club_id:
 | 
						|
                        raise ValidationError(_('The role {role} does not apply to the club {club}.')
 | 
						|
                                              .format(role=role.name, club=club.name))
 | 
						|
        else:
 | 
						|
            if Membership.objects.filter(
 | 
						|
                    user=self.user,
 | 
						|
                    club=self.club,
 | 
						|
                    date_start__lte=self.date_start,
 | 
						|
                    date_end__gte=self.date_start,
 | 
						|
            ).exists():
 | 
						|
                raise ValidationError(_('User is already a member of the club'))
 | 
						|
 | 
						|
            if self.club.parent_club is not None:
 | 
						|
                # Check that the user is already a member of the parent club if the membership is created
 | 
						|
                if not Membership.objects.filter(
 | 
						|
                    user=self.user,
 | 
						|
                    club=self.club.parent_club,
 | 
						|
                    date_start__gte=self.club.parent_club.membership_start,
 | 
						|
                ).exists():
 | 
						|
                    if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
 | 
						|
                        self.renew_parent()
 | 
						|
                    else:
 | 
						|
                        raise ValidationError(_('User is not a member of the parent club')
 | 
						|
                                              + ' ' + self.club.parent_club.name)
 | 
						|
 | 
						|
            self.fee = self.club.membership_fee_paid if self.user.profile.paid else self.club.membership_fee_unpaid
 | 
						|
 | 
						|
            self.date_end = self.date_start + datetime.timedelta(days=self.club.membership_duration) \
 | 
						|
                if self.club.membership_duration is not None else self.date_start + datetime.timedelta(days=424242)
 | 
						|
            if self.club.membership_end is not None and self.date_end > self.club.membership_end:
 | 
						|
                self.date_end = self.club.membership_end
 | 
						|
 | 
						|
        super().save(*args, **kwargs)
 | 
						|
 | 
						|
        self.make_transaction()
 | 
						|
 | 
						|
    @property
 | 
						|
    def valid(self):
 | 
						|
        """
 | 
						|
        A membership is valid if today is between the start and the end date.
 | 
						|
        """
 | 
						|
        if self.date_end is not None:
 | 
						|
            return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal()
 | 
						|
        else:
 | 
						|
            return self.date_start.toordinal() <= datetime.datetime.now().toordinal()
 | 
						|
 | 
						|
    def renew(self):
 | 
						|
        """
 | 
						|
        If the current membership comes to expiration, create a new membership that starts immediately after this one.
 | 
						|
        """
 | 
						|
        if not Membership.objects.filter(
 | 
						|
                user=self.user,
 | 
						|
                club=self.club,
 | 
						|
                date_start__gte=self.club.membership_start,
 | 
						|
        ).exists():
 | 
						|
            # Membership is not renewed yet
 | 
						|
            new_membership = Membership(
 | 
						|
                user=self.user,
 | 
						|
                club=self.club,
 | 
						|
                date_start=max(self.date_end + datetime.timedelta(days=1), self.club.membership_start),
 | 
						|
            )
 | 
						|
            if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
 | 
						|
                new_membership._force_renew_parent = True
 | 
						|
            if hasattr(self, '_force_save') and self._force_save:
 | 
						|
                new_membership._force_save = True
 | 
						|
            new_membership.save()
 | 
						|
            new_membership.roles.set(self.roles.all())
 | 
						|
            new_membership.save()
 | 
						|
 | 
						|
    def renew_parent(self):
 | 
						|
        """
 | 
						|
        Ensure that the parent membership is renewed, and renew/create it if needed.
 | 
						|
        """
 | 
						|
        parent_membership = Membership.objects.filter(
 | 
						|
            user=self.user,
 | 
						|
            club=self.club.parent_club,
 | 
						|
        ).order_by("-date_start")
 | 
						|
        if parent_membership.exists():
 | 
						|
            # Renew the previous membership of the parent club
 | 
						|
            parent_membership = parent_membership.first()
 | 
						|
            parent_membership._force_renew_parent = True
 | 
						|
            if hasattr(self, '_force_save'):
 | 
						|
                parent_membership._force_save = True
 | 
						|
            parent_membership.renew()
 | 
						|
        else:
 | 
						|
            # Create a new membership in the parent club
 | 
						|
            parent_membership = Membership(
 | 
						|
                user=self.user,
 | 
						|
                club=self.club.parent_club,
 | 
						|
                date_start=self.date_start,
 | 
						|
            )
 | 
						|
            parent_membership._force_renew_parent = True
 | 
						|
            if hasattr(self, '_force_save'):
 | 
						|
                parent_membership._force_save = True
 | 
						|
            parent_membership.save()
 | 
						|
            parent_membership.refresh_from_db()
 | 
						|
 | 
						|
            if self.club.parent_club.name == "BDE":
 | 
						|
                parent_membership.roles.set(
 | 
						|
                    Role.objects.filter(Q(name="Adhérent⋅e BDE") | Q(name="Membre de club")).all())
 | 
						|
            elif self.club.parent_club.name == "Kfet":
 | 
						|
                parent_membership.roles.set(
 | 
						|
                    Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all())
 | 
						|
            else:
 | 
						|
                parent_membership.roles.set(Role.objects.filter(name="Membre de club").all())
 | 
						|
            parent_membership.save()
 | 
						|
 | 
						|
    def make_transaction(self):
 | 
						|
        """
 | 
						|
        Create Membership transaction associated to this membership.
 | 
						|
        """
 | 
						|
        if not self.fee or MembershipTransaction.objects.filter(membership=self).exists():
 | 
						|
            return
 | 
						|
 | 
						|
        if self.fee:
 | 
						|
            transaction = MembershipTransaction(
 | 
						|
                membership=self,
 | 
						|
                source=self.user.note,
 | 
						|
                destination=self.club.note,
 | 
						|
                quantity=1,
 | 
						|
                amount=self.fee,
 | 
						|
                reason="Adhésion " + self.club.name,
 | 
						|
            )
 | 
						|
            transaction._force_save = True
 | 
						|
            if hasattr(self, '_soge') and "treasury" in settings.INSTALLED_APPS\
 | 
						|
                    and (self.club.name == "BDE" or self.club.name == "Kfet"
 | 
						|
                         or ("wei" in settings.INSTALLED_APPS and hasattr(self.club, "weiclub") and self.club.weiclub)):
 | 
						|
                # If the soge pays, then the transaction is unvalidated in a first time, then submitted for control
 | 
						|
                # to treasurers.
 | 
						|
                transaction.valid = False
 | 
						|
                from treasury.models import SogeCredit
 | 
						|
                if SogeCredit.objects.filter(user=self.user).exists():
 | 
						|
                    soge_credit = SogeCredit.objects.get(user=self.user)
 | 
						|
                else:
 | 
						|
                    soge_credit = SogeCredit(user=self.user)
 | 
						|
                    soge_credit._force_save = True
 | 
						|
                    soge_credit.save(force_insert=True)
 | 
						|
                    soge_credit.refresh_from_db()
 | 
						|
                transaction.save(force_insert=True)
 | 
						|
                transaction.refresh_from_db()
 | 
						|
                soge_credit.transactions.add(transaction)
 | 
						|
                soge_credit.save()
 | 
						|
            else:
 | 
						|
                transaction.save(force_insert=True)
 |