# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date, datetime
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.template import loader
from django.urls import reverse, reverse_lazy
from django.utils import timezone, translation
from django.utils.crypto import get_random_string
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from polymorphic.models import PolymorphicModel
from tfjm import helloasso
from tfjm.tokens import email_validation_token
class Registration(PolymorphicModel):
"""
Registrations store extra content that are not asked in the User Model.
This is specific to the role of the user, see StudentRegistration,
CoachRegistration or VolunteerRegistration.
"""
user = models.OneToOneField(
"auth.User",
on_delete=models.CASCADE,
verbose_name=_("user"),
)
give_contact_to_animath = models.BooleanField(
default=False,
verbose_name=_("Grant Animath to contact me in the future about other actions"),
)
email_confirmed = models.BooleanField(
default=False,
verbose_name=_("email confirmed"),
)
def send_email_validation_link(self):
"""
The account got created or the email got changed.
Send an email that contains a link to validate the address.
"""
subject = "[TFJM²] " + str(_("Activate your TFJM² account"))
token = email_validation_token.make_token(self.user)
uid = urlsafe_base64_encode(force_bytes(self.user.pk))
site = Site.objects.first()
message = loader.render_to_string('registration/mails/email_validation_email.txt',
{
'user': self.user,
'domain': site.domain,
'token': token,
'uid': uid,
})
html = loader.render_to_string('registration/mails/email_validation_email.html',
{
'user': self.user,
'domain': site.domain,
'token': token,
'uid': uid,
})
self.user.email_user(subject, message, html_message=html)
@property
def type(self): # pragma: no cover
raise NotImplementedError
@property
def form_class(self): # pragma: no cover
raise NotImplementedError
@property
def participates(self):
return isinstance(self, ParticipantRegistration)
@property
def is_student(self):
return isinstance(self, StudentRegistration)
@property
def is_coach(self):
return isinstance(self, CoachRegistration)
@property
def is_admin(self):
return isinstance(self, VolunteerRegistration) and self.admin or self.user.is_superuser
@property
def is_volunteer(self):
return isinstance(self, VolunteerRegistration)
def get_absolute_url(self):
return reverse_lazy("registration:user_detail", args=(self.user_id,))
def registration_informations(self):
return []
def important_informations(self):
informations = []
if not self.email_confirmed:
text = _("Your email address is not validated. Please click on the link you received by email. "
"You can resend a mail by clicking on this link.")
send_email_url = reverse_lazy("registration:email_validation_resend", args=(self.user_id,))
content = format_lazy(text, send_email_url=send_email_url)
informations.append({
'title': "Validation e-mail",
'type': "warning",
'priority': 0,
'content': content,
})
informations.extend(self.registration_informations())
informations.sort(key=lambda info: (info['priority'], info['title']))
return informations
def __str__(self):
return f"{self.user.first_name} {self.user.last_name}"
class Meta:
verbose_name = _("registration")
verbose_name_plural = _("registrations")
def get_random_photo_filename(instance, filename):
return "authorization/photo/" + get_random_string(64)
def get_random_health_filename(instance, filename):
return "authorization/health/" + get_random_string(64)
def get_random_vaccine_filename(instance, filename):
return "authorization/vaccine/" + get_random_string(64)
def get_random_parental_filename(instance, filename):
return "authorization/parental/" + get_random_string(64)
class ParticipantRegistration(Registration):
team = models.ForeignKey(
"participation.Team",
related_name="participants",
on_delete=models.PROTECT,
blank=True,
null=True,
default=None,
verbose_name=_("team"),
)
gender = models.CharField(
max_length=6,
verbose_name=_("gender"),
choices=[
("female", _("Female")),
("male", _("Male")),
("other", _("Other")),
],
default="other",
)
address = models.CharField(
max_length=255,
verbose_name=_("address"),
)
zip_code = models.PositiveIntegerField(
verbose_name=_("zip code"),
validators=[MinValueValidator(1000), MaxValueValidator(99999)],
)
city = models.CharField(
max_length=255,
verbose_name=_("city"),
)
phone_number = PhoneNumberField(
verbose_name=_("phone number"),
blank=True,
)
health_issues = models.TextField(
verbose_name=_("health issues"),
blank=True,
help_text=_("You can indicate here your allergies or anything that is important to know for organizers."),
)
housing_constraints = models.TextField(
verbose_name=_("housing constraints"),
blank=True,
help_text=_("You can fill in something here if you have any housing constraints, "
"e.g. medical problems, scheduling issues, gender issues, "
"or anything else you feel is relevant to the organizers. "
"Leave empty if you have nothing specific to declare."),
)
photo_authorization = models.FileField(
verbose_name=_("photo authorization"),
upload_to=get_random_photo_filename,
blank=True,
default="",
)
@property
def under_18(self):
if isinstance(self, CoachRegistration):
return False # In normal case
important_date = timezone.now().date()
if self.team and self.team.participation.tournament:
important_date = self.team.participation.tournament.date_start
if self.team.participation.final:
from participation.models import Tournament
important_date = Tournament.final_tournament().date_start
return (important_date - self.birth_date).days < 18 * 365.24
@property
def type(self): # pragma: no cover
raise NotImplementedError
@property
def form_class(self): # pragma: no cover
raise NotImplementedError
def registration_informations(self):
informations = []
if not self.team:
text = _("You are not in a team. You can create one "
"or join an existing one to participate.")
create_url = reverse_lazy("participation:create_team")
join_url = reverse_lazy("participation:join_team")
content = format_lazy(text, create_url=create_url, join_url=join_url)
informations.append({
'title': _("No team"),
'type': "danger",
'priority': 1,
'content': content,
})
else:
if self.team.participation.tournament:
if not self.photo_authorization:
text = _("You have not uploaded your photo authorization. "
"You can do it by clicking on this link.")
photo_url = reverse_lazy("registration:upload_user_photo_authorization", args=(self.id,))
content = format_lazy(text, photo_url=photo_url)
informations.append({
'title': _("Photo authorization"),
'type': "danger",
'priority': 5,
'content': content,
})
informations.extend(self.team.important_informations())
return informations
class Meta:
verbose_name = _("participant registration")
verbose_name_plural = _("participant registrations")
class StudentRegistration(ParticipantRegistration):
"""
Specific registration for students.
They have a team, a student class and a school.
"""
birth_date = models.DateField(
verbose_name=_("birth date"),
default=date.today,
)
student_class = models.IntegerField(
choices=[
(12, _("12th grade")),
(11, _("11th grade")),
(10, _("10th grade or lower")),
],
verbose_name=_("student class"),
)
school = models.CharField(
max_length=255,
verbose_name=_("school"),
)
responsible_name = models.CharField(
max_length=255,
verbose_name=_("responsible name"),
default="",
)
responsible_phone = PhoneNumberField(
verbose_name=_("responsible phone number"),
default="",
)
responsible_email = models.EmailField(
verbose_name=_("responsible email address"),
default="",
)
parental_authorization = models.FileField(
verbose_name=_("parental authorization"),
upload_to=get_random_parental_filename,
blank=True,
default="",
)
health_sheet = models.FileField(
verbose_name=_("health sheet"),
upload_to=get_random_health_filename,
blank=True,
default="",
)
vaccine_sheet = models.FileField(
verbose_name=_("vaccine sheet"),
upload_to=get_random_vaccine_filename,
blank=True,
default="",
)
@property
def type(self):
return _("student")
@property
def form_class(self):
from registration.forms import StudentRegistrationForm
return StudentRegistrationForm
def registration_informations(self):
informations = super().registration_informations()
if self.team and self.team.participation.tournament and self.under_18:
if not self.parental_authorization:
text = _("You have not uploaded your parental authorization. "
"You can do it by clicking on this link.")
parental_url = reverse_lazy("registration:upload_user_parental_authorization", args=(self.id,))
content = format_lazy(text, parental_url=parental_url)
informations.append({
'title': _("Parental authorization"),
'type': "danger",
'priority': 5,
'content': content,
})
if not self.health_sheet:
text = _("You have not uploaded your health sheet. "
"You can do it by clicking on this link.")
health_url = reverse_lazy("registration:upload_user_health_sheet", args=(self.id,))
content = format_lazy(text, health_url=health_url)
informations.append({
'title': _("Health sheet"),
'type': "danger",
'priority': 5,
'content': content,
})
if not self.vaccine_sheet:
text = _("You have not uploaded your vaccine sheet. "
"You can do it by clicking on this link.")
vaccine_url = reverse_lazy("registration:upload_user_vaccine_sheet", args=(self.id,))
content = format_lazy(text, vaccine_url=vaccine_url)
informations.append({
'title': _("Vaccine sheet"),
'type': "danger",
'priority': 5,
'content': content,
})
if self.team and self.team.participation.valid:
for payment in self.payments.all():
if payment.valid is False:
text = _("You have to pay {amount} € for your registration, or send a scholarship "
"notification or a payment proof. "
"You can do it on the payment page.")
url = reverse_lazy("registration:update_payment", args=(payment.id,))
content = format_lazy(text, amount=payment.amount, url=url)
informations.append({
'title': _("Payment"),
'type': "danger",
'priority': 3,
'content': content,
})
elif self.payment.valid is None:
text = _("Your payment is under approval.")
content = text
informations.append({
'title': _("Payment"),
'type': "warning",
'priority': 3,
'content': content,
})
return informations
class Meta:
verbose_name = _("student registration")
verbose_name_plural = _("student registrations")
class CoachRegistration(ParticipantRegistration):
"""
Specific registration for coaches.
They have a team and a professional activity.
"""
last_degree = models.CharField(
max_length=255,
default="",
verbose_name=_("most recent degree in mathematics, computer science or physics"),
help_text=_("Your most recent degree in maths, computer science or physics, "
"or your last entrance exam (CAPES, Agrégation,…)"),
)
professional_activity = models.TextField(
verbose_name=_("professional activity"),
)
@property
def type(self):
return _("coach")
@property
def form_class(self):
from registration.forms import CoachRegistrationForm
return CoachRegistrationForm
class Meta:
verbose_name = _("coach registration")
verbose_name_plural = _("coach registrations")
class VolunteerRegistration(Registration):
"""
Specific registration for organizers and juries.
"""
professional_activity = models.TextField(
verbose_name=_("professional activity"),
)
admin = models.BooleanField(
verbose_name=_("administrator"),
help_text=_("An administrator has all rights. Please don't give this right to all juries and volunteers."),
default=False,
)
@property
def interesting_tournaments(self) -> set:
return set(self.organized_tournaments.all()).union(map(lambda pool: pool.tournament, self.jury_in.all()))
@property
def type(self):
return _('admin') if self.is_admin else _('volunteer')
@property
def form_class(self):
from registration.forms import VolunteerRegistrationForm
return VolunteerRegistrationForm
def important_informations(self):
informations = []
for tournament in self.organized_tournaments.all():
if timezone.now() < tournament.inscription_limit \
or tournament.participations.filter(valid=True).count() < tournament.max_teams:
text = _("Registrations for tournament {tournament} are closing on {date:%Y-%m-%d %H:%M}. "
"There are for now {validated_teams} validated teams (+ {pending_teams} pending) "
"on {max_teams} expected.")
content = format_lazy(text, tournament=tournament.name, date=tournament.inscription_limit,
validated_teams=tournament.participations.filter(valid=True).count(),
pending_teams=tournament.participations.filter(valid=False).count(),
max_teams=tournament.max_teams)
informations.append({
'title': _("Registrations"),
'type': "info",
'priority': 2,
'content': content,
})
for pending_participation in tournament.participations.filter(valid=False).all():
text = _("The team {trigram} requested to be validated for the tournament of {tournament}. "
"You can check the status of the team on the team page.")
url = reverse_lazy("participation:team_detail", args=(pending_participation.team.id,))
content = format_lazy(text, trigram=pending_participation.team.trigram,
tournament=tournament.name, url=url)
informations.append({
'title': _("Pending validation"),
'type': "warning",
'priority': 4,
'content': content,
})
payments = Payment.objects.filter(registrations__team__participation__tournament=tournament).all()
valid = payments.filter(valid=True).count()
pending = payments.filter(valid=None).count()
invalid = payments.filter(valid=False).count()
if pending + invalid > 0:
text = _("There are {valid} validated payments, {pending} pending and {invalid} invalid for the "
"tournament of {tournament}. You can check the status of the payments on the "
"payments list.")
url = reverse_lazy("participation:tournament_payments", args=(tournament.id,))
content = format_lazy(text, valid=valid, pending=pending, invalid=invalid, tournament=tournament.name,
url=url)
informations.append({
'title': _("Payments"),
'type': "info",
'priority': 5,
'content': content,
})
return informations
class Meta:
verbose_name = _("volunteer registration")
verbose_name_plural = _("volunteer registrations")
def get_receipt_filename(instance, filename):
return f"authorization/receipt/receipt_{instance.id}"
def get_random_token():
return get_random_string(32)
class Payment(models.Model):
registrations = models.ManyToManyField(
ParticipantRegistration,
related_name="payments",
verbose_name=_("registrations"),
)
grouped = models.BooleanField(
verbose_name=_("grouped"),
default=False,
help_text=_("If set to true, then one payment is made for the full team, "
"for example if the school pays for all."),
)
amount = models.PositiveSmallIntegerField(
verbose_name=_("total amount"),
help_text=_("Corresponds to the total required amount to pay, in euros."),
default=0,
)
token = models.CharField(
verbose_name=_("token"),
max_length=32,
default=get_random_token,
help_text=_("A token to authorize external users to make this payment."),
)
final = models.BooleanField(
verbose_name=_("for final tournament"),
default=False,
)
type = models.CharField(
verbose_name=_("type"),
max_length=16,
choices=[
('', _("No payment")),
('helloasso', _("Credit card")),
('scholarship', _("Scholarship")),
('bank_transfer', _("Bank transfer")),
('other', _("Other (please indicate)")),
('free', _("The tournament is free")),
],
blank=True,
default="",
)
checkout_intent_id = models.IntegerField(
verbose_name=_("Hello Asso checkout intent ID"),
blank=True,
null=True,
default=None,
)
receipt = models.FileField(
verbose_name=_("receipt"),
help_text=_("only if you have a scholarship or if you chose a bank transfer."),
upload_to=get_receipt_filename,
blank=True,
default="",
)
additional_information = models.TextField(
verbose_name=_("additional information"),
help_text=_("To help us to find your payment."),
blank=True,
default="",
)
valid = models.BooleanField(
verbose_name=_("payment valid"),
null=True,
default=False,
)
@property
def team(self):
return self.registrations.first().team
team.fget.short_description = _("team")
team.fget.admin_order_field = 'registrations__team__trigram'
@property
def tournament(self):
if self.final:
from participation.models import Tournament
return Tournament.final_tournament()
return self.registrations.first().team.participation.tournament
tournament.fget.short_description = _("tournament")
tournament.fget.admin_order_field = 'registrations__team__participation__tournament'
def get_checkout_intent(self, none_if_link_disabled=False):
if self.checkout_intent_id is None:
return None
return helloasso.get_checkout_intent(self.checkout_intent_id, none_if_link_disabled=none_if_link_disabled)
def create_checkout_intent(self):
checkout_intent = self.get_checkout_intent(none_if_link_disabled=True)
if checkout_intent is not None:
return checkout_intent
tournament = self.tournament
year = datetime.now().year
base_site = "https://" + Site.objects.first().domain
checkout_intent = helloasso.create_checkout_intent(
amount=100 * self.amount,
name=f"Participation au TFJM² {year} - {tournament.name} - {self.team.trigram}",
back_url=base_site + reverse('registration:update_payment', args=(self.id,)),
error_url=f"{base_site}{reverse('registration:payment_hello_asso_return', args=(self.id,))}?type=error",
return_url=f"{base_site}{reverse('registration:payment_hello_asso_return', args=(self.id,))}?type=return",
contains_donation=False,
metadata=dict(
users=[
dict(user_id=registration.user.id,
first_name=registration.user.first_name,
last_name=registration.user.last_name,
email=registration.user.email,)
for registration in self.registrations.all()
],
payment_id=self.id,
final=self.final,
tournament_id=tournament.id,
)
)
self.checkout_intent_id = checkout_intent["id"]
self.save()
return checkout_intent
def send_remind_mail(self):
translation.activate('fr')
subject = "[TFJM²] " + str(_("Reminder for your payment"))
site = Site.objects.first()
for registration in self.registrations.all():
message = loader.render_to_string('registration/mails/payment_reminder.txt',
dict(registration=registration, payment=self, domain=site.domain))
html = loader.render_to_string('registration/mails/payment_reminder.html',
dict(registration=registration, payment=self, domain=site.domain))
registration.user.email_user(subject, message, html_message=html)
def send_helloasso_payment_confirmation_mail(self):
translation.activate('fr')
subject = "[TFJM²] " + str(_("Payment confirmation"))
site = Site.objects.first()
for registration in self.registrations.all():
message = loader.render_to_string('registration/mails/payment_confirmation.txt',
dict(registration=registration, payment=self, domain=site.domain))
html = loader.render_to_string('registration/mails/payment_confirmation.html',
dict(registration=registration, payment=self, domain=site.domain))
registration.user.email_user(subject, message, html_message=html)
payer = self.get_checkout_intent()['order']['payer']
payer_name = f"{payer['firstName']} {payer['lastName']}"
if not self.registrations.filter(user__email=payer['email']).exists():
message = loader.render_to_string('registration/mails/payment_confirmation.txt',
dict(registration=payer_name, payment=self, domain=site.domain))
html = loader.render_to_string('registration/mails/payment_confirmation.html',
dict(registration=payer_name, payment=self, domain=site.domain))
send_mail(subject, message, None, [payer['email']], html_message=html)
def get_absolute_url(self):
return reverse_lazy("registration:update_payment", args=(self.pk,))
def __str__(self):
return _("Payment of {registrations}").format(registrations=", ".join(map(str, self.registrations.all())))
class Meta:
verbose_name = _("payment")
verbose_name_plural = _("payments")