# Copyright (C) 2020 by Animath # SPDX-License-Identifier: GPL-3.0-or-later from datetime import date, datetime from django.conf import settings 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.timezone import localtime 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"), ) country = models.CharField( max_length=255, verbose_name=_("country"), default="France", ) 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="", ) photo_authorization_final = models.FileField( verbose_name=_("photo authorization (final)"), 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 = localtime(timezone.now()).date() if self.team and self.team.participation.tournament: important_date = self.team.participation.tournament.date_start birth_date = self.birth_date if birth_date.month == 2 and birth_date.day == 29: # If the birth date is the 29th of February, we consider it as the 1st of March birth_date = birth_date.replace(month=3, day=1) over_18_on = birth_date.replace(year=birth_date.year + 18) return important_date < over_18_on @property def under_18_final(self): if isinstance(self, CoachRegistration): return False # In normal case from participation.models import Tournament important_date = Tournament.final_tournament().date_start birth_date = self.birth_date if birth_date.month == 2 and birth_date.day == 29: # If the birth date is the 29th of February, we consider it as the 1st of March birth_date = birth_date.replace(month=3, day=1) over_18_on = birth_date.replace(year=birth_date.year + 18) return important_date < over_18_on @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, }) if self.team.participation.final: if not self.photo_authorization_final: text = _("You have not uploaded your photo authorization for the final tournament. " "You can do it by clicking on this link.") photo_url = reverse_lazy("registration:upload_user_photo_authorization_final", 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 def send_email_final_selection(self): """ The team is selected for final. """ translation.activate(settings.PREFERRED_LANGUAGE_CODE) subject = "[TFJM²] " + str(_("Team selected for the final tournament")) site = Site.objects.first() from participation.models import Tournament tournament = Tournament.final_tournament() payment = self.payments.filter(final=True).first() if self.is_student else None message = loader.render_to_string('registration/mails/final_selection.txt', { 'user': self.user, 'domain': site.domain, 'tournament': tournament, 'payment': payment, }) html = loader.render_to_string('registration/mails/final_selection.html', { 'user': self.user, 'domain': site.domain, 'tournament': tournament, 'payment': payment, }) self.user.email_user(subject, message, html_message=html) 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="", ) parental_authorization_final = models.FileField( verbose_name=_("parental authorization (final)"), 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 settings.HEALTH_SHEET_REQUIRED and 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 settings.VACCINE_SHEET_REQUIRED and 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 payment.valid is None: text = _("Your payment is under approval.") content = text informations.append({ 'title': _("Payment"), 'type': "warning", 'priority': 3, 'content': content, }) if self.team.participation.final: if self.under_18_final and not self.parental_authorization_final: text = _("You have not uploaded your parental authorization for the final tournament. " "You can do it by clicking on this link.") parental_url = reverse_lazy("registration:upload_user_parental_authorization_final", args=(self.id,)) content = format_lazy(text, parental_url=parental_url) informations.append({ 'title': _("Parental authorization"), 'type': "danger", 'priority': 5, '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=localtime(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, }) if timezone.now() > tournament.solution_limit and timezone.now() < tournament.solutions_draw: text = _("
The draw of the solutions for the tournament {tournament} is planned on the " "{date:%Y-%m-%d %H:%M}. You can join it on this link.
") url = reverse_lazy("draw:index") content = format_lazy(text, tournament=tournament.name, date=localtime(tournament.solutions_draw), url=url) informations.append({ 'title': _("Draw of solutions"), 'type': "info", 'priority': 1, 'content': content, }) for tournament in self.interesting_tournaments: pools = tournament.pools.filter(juries=self).order_by('round').all() for pool in pools: if pool.round == 1 and timezone.now().date() <= tournament.date_start: text = _("You are in the jury of the pool {pool} for the tournament of {tournament}. " "You can find the pool page here.
") pool_url = reverse_lazy("participation:pool_detail", args=(pool.id,)) content = format_lazy(text, pool=pool.short_name, tournament=tournament.name, pool_url=pool_url) informations.append({ 'title': _("First round"), 'type': "info", 'priority': 1, 'content': content, }) elif pool.round == 2 and timezone.now().date() <= tournament.date_end: text = _("You are in the jury of the pool {pool} for the tournament of {tournament}. " "You can find the pool page here.
") pool_url = reverse_lazy("participation:pool_detail", args=(pool.id,)) content = format_lazy(text, pool=pool.short_name, tournament=tournament.name, pool_url=pool_url) informations.append({ 'title': _("Second round"), 'type': "info", 'priority': 2, 'content': content, }) for note in self.notes.filter(passage__pool=pool).all(): if not note.has_any_note(): text = _("You don't have given any note as a jury for the passage {passage} " "in the pool {pool} of {tournament}. " "You can set your notes here.
") passage_url = reverse_lazy("participation:passage_detail", args=(note.passage.id,)) content = format_lazy(text, passage=note.passage.position, pool=pool.short_name, tournament=tournament.name, passage_url=passage_url) informations.append({ 'title': _("Note"), 'type': "warning", 'priority': 3 + note.passage.position, '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(settings.PREFERRED_LANGUAGE_CODE) 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(settings.PREFERRED_LANGUAGE_CODE) 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")