nk20/apps/member/models.py

508 lines
18 KiB
Python
Raw Normal View History

# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay
2019-07-16 10:43:23 +00:00
# SPDX-License-Identifier: GPL-3.0-or-later
2019-09-18 12:26:42 +00:00
import datetime
2020-04-09 20:03:26 +00:00
import os
2019-09-18 12:26:42 +00:00
2019-07-16 10:43:23 +00:00
from django.conf import settings
2020-04-01 01:42:19 +00:00
from django.contrib.auth.models import User
2020-04-01 02:20:47 +00:00
from django.core.exceptions import ValidationError
2020-09-11 20:52:16 +00:00
from django.db import models, transaction
2020-08-05 10:22:35 +00:00
from django.db.models import Q
from django.template import loader
2019-08-15 19:49:32 +00:00
from django.urls import reverse, reverse_lazy
2020-08-05 21:19:17 +00:00
from django.utils import timezone
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
2020-03-07 21:28:59 +00:00
from django.utils.translation import gettext_lazy as _
2020-08-05 12:14:51 +00:00
from phonenumber_field.modelfields import PhoneNumberField
2020-08-05 10:22:35 +00:00
from permission.models import Role
2020-04-05 07:48:23 +00:00
from registration.tokens import email_validation_token
2020-04-01 01:42:19 +00:00
from note.models import MembershipTransaction
2019-09-18 12:26:42 +00:00
2019-07-16 10:43:23 +00:00
class Profile(models.Model):
"""
An user profile
We do not want to patch the Django Contrib :model:`auth.User`model;
2019-07-16 10:43:23 +00:00
so this model add an user profile with additional information.
"""
2019-07-16 10:43:23 +00:00
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
)
2020-08-05 12:14:51 +00:00
phone_number = PhoneNumberField(
2019-07-16 10:43:23 +00:00
verbose_name=_('phone number'),
max_length=50,
blank=True,
null=True,
)
2019-07-16 10:43:23 +00:00
section = models.CharField(
verbose_name=_('section'),
help_text=_('e.g. "1A0", "9A♥", "SAPHIRE"'),
max_length=255,
2019-07-16 11:54:32 +00:00
blank=True,
default="",
2019-07-16 10:43:23 +00:00
)
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)"),
)
2019-07-16 10:43:23 +00:00
address = models.CharField(
verbose_name=_('address'),
max_length=255,
blank=True,
default="",
2019-07-16 10:43:23 +00:00
)
2019-07-16 10:43:23 +00:00
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)"),
)
2020-08-05 21:19:17 +00:00
report_frequency = models.PositiveSmallIntegerField(
verbose_name=_("report frequency (in days)"),
default=0,
)
last_report = models.DateTimeField(
2020-08-05 21:19:17 +00:00
verbose_name=_("last report date"),
default=timezone.now,
2020-08-05 21:19:17 +00:00
)
email_confirmed = models.BooleanField(
verbose_name=_("email confirmed"),
default=False,
)
2020-04-05 04:40:03 +00:00
registration_valid = models.BooleanField(
verbose_name=_("registration valid"),
default=False,
)
@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
2019-07-16 10:43:23 +00:00
class Meta:
verbose_name = _('user profile')
verbose_name_plural = _('user profile')
2020-03-11 16:10:16 +00:00
indexes = [models.Index(fields=['user'])]
2019-07-16 10:43:23 +00:00
2019-08-11 22:30:29 +00:00
def get_absolute_url(self):
2020-08-09 11:42:37 +00:00
return reverse('member:user_detail', args=(self.user_id,))
def __str__(self):
return str(self.user)
2020-02-18 11:31:15 +00:00
def send_email_validation_link(self):
subject = "[Note Kfet] " + str(_("Activate your Note Kfet account"))
2020-09-03 18:03:40 +00:00
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,
2020-04-09 20:03:26 +00:00
'domain': os.getenv("NOTE_URL", "note.example.com"),
2020-09-03 18:03:40 +00:00
'token': token,
'uid': uid,
})
html = loader.render_to_string('registration/mails/email_validation_email.html',
2020-08-06 10:50:24 +00:00
{
'user': self.user,
'domain': os.getenv("NOTE_URL", "note.example.com"),
2020-09-03 18:03:40 +00:00
'token': token,
'uid': uid,
2020-08-06 10:50:24 +00:00
})
self.user.email_user(subject, message, html_message=html)
2019-07-16 10:43:23 +00:00
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`.
2019-07-16 10:43:23 +00:00
"""
name = models.CharField(
2019-07-16 13:17:57 +00:00
verbose_name=_('name'),
2019-07-16 10:43:23 +00:00
max_length=255,
2019-07-16 13:17:57 +00:00
unique=True,
2019-07-16 10:43:23 +00:00
)
email = models.EmailField(
verbose_name=_('email'),
)
parent_club = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.PROTECT,
verbose_name=_('parent club'),
)
2019-07-16 10:43:23 +00:00
# Memberships
2020-03-31 14:22:11 +00:00
# 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"),
2020-03-31 21:54:14 +00:00
help_text=_("Uncheck if this club don't require memberships."),
2020-03-31 14:22:11 +00:00
)
membership_fee_paid = models.PositiveIntegerField(
2020-03-31 14:22:11 +00:00
default=0,
verbose_name=_('membership fee (paid students)'),
)
membership_fee_unpaid = models.PositiveIntegerField(
default=0,
verbose_name=_('membership fee (unpaid students)'),
2019-07-16 10:43:23 +00:00
)
2020-03-31 21:54:14 +00:00
2020-04-01 01:42:19 +00:00
membership_duration = models.PositiveIntegerField(
2020-03-31 14:22:11 +00:00
blank=True,
2019-07-16 10:43:23 +00:00
null=True,
verbose_name=_('membership duration'),
2020-03-31 21:54:14 +00:00
help_text=_('The longest time (in days) a membership can last '
2019-07-16 10:59:11 +00:00
'(NULL = infinite).'),
2019-07-16 10:43:23 +00:00
)
2020-03-31 21:54:14 +00:00
membership_start = models.DateField(
2020-03-31 14:22:11 +00:00
blank=True,
2019-07-16 10:43:23 +00:00
null=True,
verbose_name=_('membership start'),
help_text=_('Date from which the members can renew their membership.'),
2019-07-16 10:43:23 +00:00
)
2020-03-31 21:54:14 +00:00
membership_end = models.DateField(
2020-03-31 14:22:11 +00:00
blank=True,
2019-07-16 10:43:23 +00:00
null=True,
verbose_name=_('membership end'),
help_text=_('Maximal date of a membership, after which members must renew it.'),
2019-07-16 10:43:23 +00:00
)
2020-03-31 21:54:14 +00:00
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:
return
2020-03-31 21:54:14 +00:00
today = datetime.date.today()
if (today - self.membership_start).days >= 365:
self.membership_start = datetime.date(self.membership_start.year + 1,
self.membership_start.month, self.membership_start.day)
self.membership_end = datetime.date(self.membership_end.year + 1,
self.membership_end.month, self.membership_end.day)
2020-04-11 15:42:08 +00:00
self._force_save = True
2020-03-31 21:54:14 +00:00
self.save(force_update=True)
2020-09-11 20:52:16 +00:00
@transaction.atomic
2020-03-31 14:22:11 +00:00
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
2020-03-31 14:22:11 +00:00
self.membership_duration = None
self.membership_start = None
self.membership_end = None
super().save(force_insert, force_update, update_fields)
2019-07-16 11:50:05 +00:00
class Meta:
verbose_name = _("club")
verbose_name_plural = _("clubs")
2019-07-16 13:17:57 +00:00
def __str__(self):
return self.name
2019-08-11 21:25:27 +00:00
def get_absolute_url(self):
2020-03-07 21:28:59 +00:00
return reverse_lazy('member:club_detail', args=(self.pk,))
2019-08-11 21:25:27 +00:00
2019-07-16 10:43:23 +00:00
class Membership(models.Model):
2019-08-11 15:52:41 +00:00
"""
Register the membership of a user to a club, including roles and membership duration.
2019-08-11 15:52:41 +00:00
"""
2019-07-16 10:43:23 +00:00
user = models.ForeignKey(
2020-04-01 01:42:19 +00:00
User,
2020-02-18 11:31:15 +00:00
on_delete=models.PROTECT,
2020-04-23 16:28:16 +00:00
related_name="memberships",
2020-04-01 16:47:56 +00:00
verbose_name=_("user"),
2019-07-16 10:43:23 +00:00
)
2020-03-31 21:54:14 +00:00
2019-07-16 10:43:23 +00:00
club = models.ForeignKey(
Club,
2020-02-18 11:31:15 +00:00
on_delete=models.PROTECT,
2020-04-01 16:47:56 +00:00
verbose_name=_("club"),
2019-07-16 10:43:23 +00:00
)
2020-03-31 21:54:14 +00:00
roles = models.ManyToManyField(
2020-07-25 17:40:30 +00:00
"permission.Role",
related_name="memberships",
2020-04-01 01:42:19 +00:00
verbose_name=_("roles"),
2019-07-16 10:43:23 +00:00
)
2020-03-31 21:54:14 +00:00
2019-07-16 10:43:23 +00:00
date_start = models.DateField(
default=datetime.date.today,
2019-07-16 10:43:23 +00:00
verbose_name=_('membership starts on'),
)
2020-03-31 21:54:14 +00:00
2019-07-16 10:43:23 +00:00
date_end = models.DateField(
verbose_name=_('membership ends on'),
null=True,
)
2020-03-31 21:54:14 +00:00
2019-07-16 10:43:23 +00:00
fee = models.PositiveIntegerField(
verbose_name=_('fee'),
)
@property
2019-09-18 12:26:42 +00:00
def valid(self):
2020-04-06 06:58:39 +00:00
"""
A membership is valid if today is between the start and the end date.
"""
2019-09-18 14:41:01 +00:00
if self.date_end is not None:
2020-03-07 12:12:17 +00:00
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() < self.date_end.toordinal()
2019-09-18 14:41:01 +00:00
else:
2020-03-07 12:12:17 +00:00
return self.date_start.toordinal() <= datetime.datetime.now().toordinal()
2019-09-18 12:26:42 +00:00
def renew(self):
"""
If the current membership comes to expiration, create a new membership that starts immediately after this one.
"""
2020-09-02 20:54:01 +00:00
if not Membership.objects.filter(
user=self.user,
club=self.club,
date_start__gte=self.club.membership_start,
).exists():
2020-09-02 20:54:01 +00:00
# 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, '_soge') and self._soge:
new_membership._soge = 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, '_soge'):
parent_membership._soge = 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, '_soge'):
parent_membership._soge = 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 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 Kfet") | Q(name="Membre de club")).all())
else:
parent_membership.roles.set(Role.objects.filter(name="Membre de club").all())
parent_membership.save()
2020-09-11 20:52:16 +00:00
@transaction.atomic
def save(self, *args, **kwargs):
2020-04-06 06:58:39 +00:00
"""
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()
2020-09-02 20:54:01 +00:00
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))
2020-09-02 20:54:01 +00:00
else:
2020-04-01 01:42:19 +00:00
if Membership.objects.filter(
user=self.user,
club=self.club,
date_start__lte=self.date_start,
date_end__gte=self.date_start,
2020-04-01 01:42:19 +00:00
).exists():
raise ValidationError(_('User is already a member of the club'))
2020-09-02 20:54:01 +00:00
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)
2020-09-02 20:54:01 +00:00
self.fee = self.club.membership_fee_paid if self.user.profile.paid else self.club.membership_fee_unpaid
2020-09-02 20:54:01 +00:00
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
2020-03-31 21:54:14 +00:00
super().save(*args, **kwargs)
self.make_transaction()
def make_transaction(self):
2020-04-06 06:58:39 +00:00
"""
Create Membership transaction associated to this membership.
"""
2020-04-01 02:20:47 +00:00
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,
2020-04-01 02:20:47 +00:00
reason="Adhésion " + self.club.name,
)
transaction._force_save = True
if hasattr(self, '_soge') and "treasury" in settings.INSTALLED_APPS\
2020-08-06 10:50:24 +00:00
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)
2020-04-01 01:42:19 +00:00
2020-04-01 02:20:47 +00:00
def __str__(self):
return _("Membership of {user} for the club {club}").format(user=self.user.username, club=self.club.name, )
2019-07-16 10:43:23 +00:00
class Meta:
verbose_name = _('membership')
verbose_name_plural = _('memberships')
2020-03-11 16:10:16 +00:00
indexes = [models.Index(fields=['user'])]