Allow anonymous users to perform a payment using a special auth token

Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
This commit is contained in:
Emmy D'Anello 2024-02-21 22:44:50 +01:00
parent 8d08b18d08
commit b16b6e422f
Signed by: ynerant
GPG Key ID: 3A75C55819C8CF85
5 changed files with 189 additions and 59 deletions

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: TFJM\n" "Project-Id-Version: TFJM\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-02-20 22:48+0100\n" "POT-Creation-Date: 2024-02-21 22:42+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n" "Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -1831,7 +1831,7 @@ msgstr ""
msgid "registration" msgid "registration"
msgstr "inscription" msgstr "inscription"
#: registration/models.py:129 registration/models.py:513 #: registration/models.py:129 registration/models.py:517
msgid "registrations" msgid "registrations"
msgstr "inscriptions" msgstr "inscriptions"
@ -2131,11 +2131,11 @@ msgstr "inscription de bénévole"
msgid "volunteer registrations" msgid "volunteer registrations"
msgstr "inscriptions de bénévoles" msgstr "inscriptions de bénévoles"
#: registration/models.py:517 #: registration/models.py:521
msgid "grouped" msgid "grouped"
msgstr "groupé" msgstr "groupé"
#: registration/models.py:519 #: registration/models.py:523
msgid "" msgid ""
"If set to true, then one payment is made for the full team, for example if " "If set to true, then one payment is made for the full team, for example if "
"the school pays for all." "the school pays for all."
@ -2143,84 +2143,92 @@ msgstr ""
"Si vrai, alors un seul paiement est fait pour toute l'équipe, par exemple si " "Si vrai, alors un seul paiement est fait pour toute l'équipe, par exemple si "
"le lycée paie pour tout le monde." "le lycée paie pour tout le monde."
#: registration/models.py:524 #: registration/models.py:528
msgid "total amount" msgid "total amount"
msgstr "montant total" msgstr "montant total"
#: registration/models.py:525 #: registration/models.py:529
msgid "Corresponds to the total required amount to pay, in euros." msgid "Corresponds to the total required amount to pay, in euros."
msgstr "Correspond au montant total à payer, en euros." msgstr "Correspond au montant total à payer, en euros."
#: registration/models.py:530 #: registration/models.py:534
msgid "token"
msgstr "jeton"
#: registration/models.py:537
msgid "A token to authorize external users to make this payment."
msgstr "Un jeton pour autoriser des utilisateurs externes à faire ce paiement."
#: registration/models.py:541
msgid "for final tournament" msgid "for final tournament"
msgstr "pour la finale" msgstr "pour la finale"
#: registration/models.py:535 #: registration/models.py:546
msgid "type" msgid "type"
msgstr "type" msgstr "type"
#: registration/models.py:538 #: registration/models.py:549
msgid "No payment" msgid "No payment"
msgstr "Pas de paiement" msgstr "Pas de paiement"
#: registration/models.py:539 #: registration/models.py:550
#: registration/templates/registration/payment_form.html:56 #: registration/templates/registration/payment_form.html:56
msgid "Credit card" msgid "Credit card"
msgstr "Carte bancaire" msgstr "Carte bancaire"
#: registration/models.py:540 #: registration/models.py:551
msgid "Scholarship" msgid "Scholarship"
msgstr "Notification de bourse" msgstr "Notification de bourse"
#: registration/models.py:541 #: registration/models.py:552
#: registration/templates/registration/payment_form.html:61 #: registration/templates/registration/payment_form.html:61
msgid "Bank transfer" msgid "Bank transfer"
msgstr "Virement bancaire" msgstr "Virement bancaire"
#: registration/models.py:542 #: registration/models.py:553
msgid "Other (please indicate)" msgid "Other (please indicate)"
msgstr "Autre (veuillez spécifier)" msgstr "Autre (veuillez spécifier)"
#: registration/models.py:543 #: registration/models.py:554
msgid "The tournament is free" msgid "The tournament is free"
msgstr "Le tournoi est gratuit" msgstr "Le tournoi est gratuit"
#: registration/models.py:550 #: registration/models.py:561
msgid "Hello Asso checkout intent ID" msgid "Hello Asso checkout intent ID"
msgstr "ID de l'intention de paiement Hello Asso" msgstr "ID de l'intention de paiement Hello Asso"
#: registration/models.py:557 #: registration/models.py:568
msgid "receipt" msgid "receipt"
msgstr "justificatif" msgstr "justificatif"
#: registration/models.py:558 #: registration/models.py:569
msgid "only if you have a scholarship or if you chose a bank transfer." msgid "only if you have a scholarship or if you chose a bank transfer."
msgstr "" msgstr ""
"Nécessaire seulement si vous déclarez être boursièr⋅e ou si vous payez par " "Nécessaire seulement si vous déclarez être boursièr⋅e ou si vous payez par "
"virement bancaire." "virement bancaire."
#: registration/models.py:565 #: registration/models.py:576
msgid "additional information" msgid "additional information"
msgstr "informations additionnelles" msgstr "informations additionnelles"
#: registration/models.py:566 #: registration/models.py:577
msgid "To help us to find your payment." msgid "To help us to find your payment."
msgstr "Pour nous aider à retrouver votre paiement, si nécessaire." msgstr "Pour nous aider à retrouver votre paiement, si nécessaire."
#: registration/models.py:572 #: registration/models.py:583
msgid "payment valid" msgid "payment valid"
msgstr "paiement valide" msgstr "paiement valide"
#: registration/models.py:630 #: registration/models.py:641
#, python-brace-format #, python-brace-format
msgid "Payment of {registrations}" msgid "Payment of {registrations}"
msgstr "Paiements de {registrations}" msgstr "Paiements de {registrations}"
#: registration/models.py:633 #: registration/models.py:644
msgid "payment" msgid "payment"
msgstr "paiement" msgstr "paiement"
#: registration/models.py:634 #: registration/models.py:645
msgid "payments" msgid "payments"
msgstr "paiements" msgstr "paiements"
@ -2656,30 +2664,34 @@ msgstr "Mise à jour de l'utilisateur⋅rice {user}"
msgid "This payment is already valid or pending validation." msgid "This payment is already valid or pending validation."
msgstr "Le paiement est déjà validé ou en attente de validation." msgstr "Le paiement est déjà validé ou en attente de validation."
#: registration/views.py:563 #: registration/views.py:570
msgid "The payment is already valid or pending validation."
msgstr "Le paiement est déjà validé ou en attente de validation."
#: registration/views.py:584
msgid "The payment is not found or is already validated." msgid "The payment is not found or is already validated."
msgstr "Le paiement n'est pas trouvé ou déjà validé." msgstr "Le paiement n'est pas trouvé ou déjà validé."
#: registration/views.py:582 #: registration/views.py:603
#, python-brace-format #, python-brace-format
msgid "An error occurred during the payment: {error}" msgid "An error occurred during the payment: {error}"
msgstr "Une erreur est survenue lors du paiement : {error}" msgstr "Une erreur est survenue lors du paiement : {error}"
#: registration/views.py:588 #: registration/views.py:609
msgid "The payment has been refused." msgid "The payment has been refused."
msgstr "Le paiement a été refusé." msgstr "Le paiement a été refusé."
#: registration/views.py:591 #: registration/views.py:612
#, python-brace-format #, python-brace-format
msgid "The return code is unknown: {code}" msgid "The return code is unknown: {code}"
msgstr "Le code de retour est inconnu : {code}" msgstr "Le code de retour est inconnu : {code}"
#: registration/views.py:594 #: registration/views.py:615
#, python-brace-format #, python-brace-format
msgid "The return type is unknown: {type}" msgid "The return type is unknown: {type}"
msgstr "Le type de retour est inconnu : {type}" msgstr "Le type de retour est inconnu : {type}"
#: registration/views.py:601 #: registration/views.py:622
msgid "" msgid ""
"The payment has been successfully validated! Your registration is now " "The payment has been successfully validated! Your registration is now "
"complete." "complete."
@ -2687,7 +2699,7 @@ msgstr ""
"Le paiement a été validé avec succès ! Votre inscription est désormais " "Le paiement a été validé avec succès ! Votre inscription est désormais "
"complète." "complète."
#: registration/views.py:606 #: registration/views.py:627
msgid "" msgid ""
"Your payment is done! The validation of your payment may takes a few " "Your payment is done! The validation of your payment may takes a few "
"minutes, and will be automatically done. If it is not the case, please " "minutes, and will be automatically done. If it is not the case, please "
@ -2697,27 +2709,27 @@ msgstr ""
"quelques minutes, et sera faite automatiquement. Si ce n'est pas le cas, " "quelques minutes, et sera faite automatiquement. Si ce n'est pas le cas, "
"merci de nous contacter." "merci de nous contacter."
#: registration/views.py:641 #: registration/views.py:662
#, python-brace-format #, python-brace-format
msgid "Photo authorization of {student}.{ext}" msgid "Photo authorization of {student}.{ext}"
msgstr "Autorisation de droit à l'image de {student}.{ext}" msgstr "Autorisation de droit à l'image de {student}.{ext}"
#: registration/views.py:665 #: registration/views.py:686
#, python-brace-format #, python-brace-format
msgid "Health sheet of {student}.{ext}" msgid "Health sheet of {student}.{ext}"
msgstr "Fiche sanitaire de {student}.{ext}" msgstr "Fiche sanitaire de {student}.{ext}"
#: registration/views.py:689 #: registration/views.py:710
#, python-brace-format #, python-brace-format
msgid "Vaccine sheet of {student}.{ext}" msgid "Vaccine sheet of {student}.{ext}"
msgstr "Carnet de vaccination de {student}.{ext}" msgstr "Carnet de vaccination de {student}.{ext}"
#: registration/views.py:713 #: registration/views.py:734
#, python-brace-format #, python-brace-format
msgid "Parental authorization of {student}.{ext}" msgid "Parental authorization of {student}.{ext}"
msgstr "Autorisation parentale de {student}.{ext}" msgstr "Autorisation parentale de {student}.{ext}"
#: registration/views.py:736 #: registration/views.py:757
#, python-brace-format #, python-brace-format
msgid "Payment receipt of {user}.{ext}" msgid "Payment receipt of {user}.{ext}"
msgstr "Justificatif de paiement de {user}.{ext}" msgstr "Justificatif de paiement de {user}.{ext}"

View File

@ -0,0 +1,41 @@
# Generated by Django 5.0.1 on 2024-02-20 22:48
import registration.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registration", "0011_remove_payment_registration_and_more"),
]
operations = [
migrations.AddField(
model_name="payment",
name="token",
field=models.CharField(
default=registration.models.get_random_token,
help_text="A token to authorize external users to make this payment.",
max_length=32,
verbose_name="token",
),
),
migrations.AlterField(
model_name="payment",
name="type",
field=models.CharField(
blank=True,
choices=[
("", "No payment"),
("helloasso", "Credit card"),
("scholarship", "Scholarship"),
("bank_transfer", "Bank transfer"),
("other", "Other (please indicate)"),
("free", "The tournament is free"),
],
default="",
max_length=16,
verbose_name="type",
),
),
]

View File

@ -506,6 +506,10 @@ def get_receipt_filename(instance, filename):
return f"authorization/receipt/receipt_{instance.id}" return f"authorization/receipt/receipt_{instance.id}"
def get_random_token():
return get_random_string(32)
class Payment(models.Model): class Payment(models.Model):
registrations = models.ManyToManyField( registrations = models.ManyToManyField(
ParticipantRegistration, ParticipantRegistration,
@ -526,6 +530,13 @@ class Payment(models.Model):
default=0, 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( final = models.BooleanField(
verbose_name=_("for final tournament"), verbose_name=_("for final tournament"),
default=False, default=False,
@ -585,13 +596,13 @@ class Payment(models.Model):
return Tournament.final_tournament() return Tournament.final_tournament()
return self.registrations.first().team.participation.tournament return self.registrations.first().team.participation.tournament
def get_checkout_intent(self): def get_checkout_intent(self, none_if_link_disabled=False):
if self.checkout_intent_id is None: if self.checkout_intent_id is None:
return None return None
return helloasso.get_checkout_intent(self.checkout_intent_id) return helloasso.get_checkout_intent(self.checkout_intent_id, none_if_link_disabled=none_if_link_disabled)
def create_checkout_intent(self): def create_checkout_intent(self):
checkout_intent = self.get_checkout_intent() checkout_intent = self.get_checkout_intent(none_if_link_disabled=True)
if checkout_intent is not None: if checkout_intent is not None:
return checkout_intent return checkout_intent

View File

@ -79,17 +79,39 @@
<div class="card-body"> <div class="card-body">
<div class="tab-content" id="payment-form"> <div class="tab-content" id="payment-form">
<div class="tab-pane fade show active" id="credit-card" role="tabpanel" aria-labelledby="credit-card-tab"> <div class="tab-pane fade show active" id="credit-card" role="tabpanel" aria-labelledby="credit-card-tab">
<p>
Le paiement par carte bancaire s'effectue via Hello Asso. Pour cela, vous pouvez cliquer sur Le paiement par carte bancaire s'effectue via Hello Asso. Pour cela, vous pouvez cliquer sur
le bouton ci-dessous, qui vous redirigera vers la page de paiement sécurisée de Hello Asso. le bouton ci-dessous, qui vous redirigera vers la page de paiement sécurisée de Hello Asso.
La validation du paiement sera ensuite faite automatiquement, sous quelques minutes. La validation du paiement sera ensuite faite automatiquement, sous quelques minutes.
Si un tiers doit payer pour vous (parents, lycée,…), vous pouvez lui transmettre le lien pour </p>
payer pour vous.
<div class="text-center"> <div class="text-center">
<a href="{% url "registration:payment_hello_asso" pk=payment.pk %}" class="btn btn-primary"> <a href="{% url "registration:payment_hello_asso" pk=payment.pk %}" class="btn btn-primary">
<i class="fas fa-credit-card"></i> Aller sur la page Hello Asso <i class="fas fa-credit-card"></i> Aller sur la page Hello Asso
</a> </a>
</div> </div>
<p>
Si un tiers doit payer pour vous (parents, lycée,…), vous pouvez lui transmettre le lien pour
payer pour vous :
</p>
<div class="text-center border border-1 my-3 p-2 border-danger bg-body-tertiary shadow-lg rounded">
{% url "registration:payment_hello_asso" pk=payment.pk as payment_url %}
{{ request.scheme }}://{{ request.site.domain }}{{ payment_url }}?token={{ payment.token }}
<a id="copyIcon" href="#"
data-bs-title="Copié !"
onclick="event.preventDefault();copyToClipboard('{{ request.scheme }}://{{ request.site.domain }}{{ payment_url }}?token={{ payment.token }}')">
<i class="fas fa-copy"></i> Copier
</a>
</div>
<p>
Si tel est le cas et si une facture est nécessaire, merci de contacter les organisateur⋅ices
du tournoi en transmettant le nom de l'équipe, le nombre de participant⋅es, le nom de
l'établissement payeur, l'adresse mail de l'établissement et/ou l'adresse mail du ou de la
gestionnaire de l'établissement.
</p>
</div> </div>
<div class="tab-pane fade" id="bank-transfer" role="tabpanel" aria-labelledby="bank-transfer-tab"> <div class="tab-pane fade" id="bank-transfer" role="tabpanel" aria-labelledby="bank-transfer-tab">
@ -138,5 +160,28 @@
elem => elem.addEventListener( elem => elem.addEventListener(
'click', () => document.location.hash = '#' + elem.getAttribute('aria-controls'))) 'click', () => document.location.hash = '#' + elem.getAttribute('aria-controls')))
}) })
function copyToClipboard(text) {
const copyIcon = document.getElementById('copyIcon')
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
const tooltip = bootstrap.Tooltip.getOrCreateInstance(copyIcon)
tooltip.setContent('Copied!')
tooltip.show()
}
)
} else {
const input = document.createElement('input')
input.value = text
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
const tooltip = bootstrap.Tooltip.getOrCreateInstance(copyIcon)
tooltip.enable()
tooltip.show()
setTimeout(() => {tooltip.disable(); tooltip.hide()}, 2000)
}
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -7,7 +7,7 @@ from tempfile import mkdtemp
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
@ -535,21 +535,41 @@ class PaymentUpdateGroupView(LoginRequiredMixin, DetailView):
return redirect(reverse_lazy("registration:update_payment", args=(payment.pk,))) return redirect(reverse_lazy("registration:update_payment", args=(payment.pk,)))
class PaymenRedirectHelloAssoView(LoginRequiredMixin, DetailView): class PaymenRedirectHelloAssoView(AccessMixin, DetailView):
model = Payment model = Payment
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not self.request.user.is_authenticated or \ payment = self.get_object()
not self.request.user.registration.is_admin \
and (self.request.user.registration not in self.get_object().registrations.all() # An external user has the link for the payment
or self.get_object().valid is not False): token = request.GET.get('token', "")
if token and token == payment.token:
return super().dispatch(request, *args, **kwargs)
if not request.user.is_authenticated:
return self.handle_no_permission() return self.handle_no_permission()
if not request.user.registration.is_admin:
if request.user.registration.is_volunteer \
and payment.tournament not in request.user.registration.organized_tournaments.all():
return self.handle_no_permission()
if request.user.registration.is_student \
and request.user.registration not in payment.registrations.all():
return self.handle_no_permission()
if request.user.registration.is_coach \
and request.user.registration.team != payment.team:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
payment = self.get_object() payment = self.get_object()
checkout_intent = payment.create_checkout_intent() if payment.valid is not False:
raise PermissionDenied(_("The payment is already valid or pending validation."))
checkout_intent = payment.create_checkout_intent()
return redirect(checkout_intent["redirectUrl"]) return redirect(checkout_intent["redirectUrl"])
@ -558,9 +578,10 @@ class PaymentHelloAssoReturnView(DetailView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
checkout_id = request.GET.get("checkoutIntentId") checkout_id = request.GET.get("checkoutIntentId")
payment = Payment.objects.get(checkout_intent_id=checkout_id).exclude(valid=True) payment = self.get_object()
if payment != self.get_object(): payment_qs = Payment.objects.exclude(valid=True).filter(checkout_intent_id=checkout_id).filter(pk=payment.pk)
messages.error(request, _("The payment is not found or is already validated.")) if not payment_qs.exists():
messages.error(request, _("The payment is not found or is already validated."), "danger")
return redirect("index") return redirect("index")
team = payment.team team = payment.team
@ -580,18 +601,18 @@ class PaymentHelloAssoReturnView(DetailView):
return_type = request.GET.get("type") return_type = request.GET.get("type")
if return_type == "error": if return_type == "error":
messages.error(request, format_lazy(_("An error occurred during the payment: {error}"), messages.error(request, format_lazy(_("An error occurred during the payment: {error}"),
error=request.GET.get("error"))) error=request.GET.get("error")), "danger")
return error_response return error_response
elif return_type == "return": elif return_type == "return":
code = request.GET.get("code") code = request.GET.get("code")
if code == "refused": if code == "refused":
messages.error(request, _("The payment has been refused.")) messages.error(request, _("The payment has been refused."), "danger")
return error_response return error_response
elif code != "success": elif code != "succeeded":
messages.error(request, format_lazy(_("The return code is unknown: {code}"), code=code)) messages.error(request, format_lazy(_("The return code is unknown: {code}"), code=code), "danger")
return error_response return error_response
else: else:
messages.error(request, format_lazy(_("The return type is unknown: {type}"), type=return_type)) messages.error(request, format_lazy(_("The return type is unknown: {type}"), type=return_type), "danger")
return error_response return error_response
checkout_intent = payment.get_checkout_intent() checkout_intent = payment.get_checkout_intent()
@ -608,7 +629,7 @@ class PaymentHelloAssoReturnView(DetailView):
"and will be automatically done. " "and will be automatically done. "
"If it is not the case, please contact us.")) "If it is not the case, please contact us."))
if request.user.registration in payment.registrations.all(): if not request.user.is_anonymous and request.user.registration in payment.registrations.all():
success_response = redirect("registration:user_detail", args=(request.user.pk,)) success_response = redirect("registration:user_detail", args=(request.user.pk,))
elif right_to_see: elif right_to_see:
success_response = redirect("participation:team_detail", args=(team.pk,)) success_response = redirect("participation:team_detail", args=(team.pk,))