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 ""
"Project-Id-Version: TFJM\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"
"Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -1831,7 +1831,7 @@ msgstr ""
msgid "registration"
msgstr "inscription"
#: registration/models.py:129 registration/models.py:513
#: registration/models.py:129 registration/models.py:517
msgid "registrations"
msgstr "inscriptions"
@ -2131,11 +2131,11 @@ msgstr "inscription de bénévole"
msgid "volunteer registrations"
msgstr "inscriptions de bénévoles"
#: registration/models.py:517
#: registration/models.py:521
msgid "grouped"
msgstr "groupé"
#: registration/models.py:519
#: registration/models.py:523
msgid ""
"If set to true, then one payment is made for the full team, for example if "
"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 "
"le lycée paie pour tout le monde."
#: registration/models.py:524
#: registration/models.py:528
msgid "total amount"
msgstr "montant total"
#: registration/models.py:525
#: registration/models.py:529
msgid "Corresponds to the total required amount to pay, in 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"
msgstr "pour la finale"
#: registration/models.py:535
#: registration/models.py:546
msgid "type"
msgstr "type"
#: registration/models.py:538
#: registration/models.py:549
msgid "No payment"
msgstr "Pas de paiement"
#: registration/models.py:539
#: registration/models.py:550
#: registration/templates/registration/payment_form.html:56
msgid "Credit card"
msgstr "Carte bancaire"
#: registration/models.py:540
#: registration/models.py:551
msgid "Scholarship"
msgstr "Notification de bourse"
#: registration/models.py:541
#: registration/models.py:552
#: registration/templates/registration/payment_form.html:61
msgid "Bank transfer"
msgstr "Virement bancaire"
#: registration/models.py:542
#: registration/models.py:553
msgid "Other (please indicate)"
msgstr "Autre (veuillez spécifier)"
#: registration/models.py:543
#: registration/models.py:554
msgid "The tournament is free"
msgstr "Le tournoi est gratuit"
#: registration/models.py:550
#: registration/models.py:561
msgid "Hello Asso checkout intent ID"
msgstr "ID de l'intention de paiement Hello Asso"
#: registration/models.py:557
#: registration/models.py:568
msgid "receipt"
msgstr "justificatif"
#: registration/models.py:558
#: registration/models.py:569
msgid "only if you have a scholarship or if you chose a bank transfer."
msgstr ""
"Nécessaire seulement si vous déclarez être boursièr⋅e ou si vous payez par "
"virement bancaire."
#: registration/models.py:565
#: registration/models.py:576
msgid "additional information"
msgstr "informations additionnelles"
#: registration/models.py:566
#: registration/models.py:577
msgid "To help us to find your payment."
msgstr "Pour nous aider à retrouver votre paiement, si nécessaire."
#: registration/models.py:572
#: registration/models.py:583
msgid "payment valid"
msgstr "paiement valide"
#: registration/models.py:630
#: registration/models.py:641
#, python-brace-format
msgid "Payment of {registrations}"
msgstr "Paiements de {registrations}"
#: registration/models.py:633
#: registration/models.py:644
msgid "payment"
msgstr "paiement"
#: registration/models.py:634
#: registration/models.py:645
msgid "payments"
msgstr "paiements"
@ -2656,30 +2664,34 @@ msgstr "Mise à jour de l'utilisateur⋅rice {user}"
msgid "This payment is already valid or pending 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."
msgstr "Le paiement n'est pas trouvé ou déjà validé."
#: registration/views.py:582
#: registration/views.py:603
#, python-brace-format
msgid "An error occurred during the payment: {error}"
msgstr "Une erreur est survenue lors du paiement : {error}"
#: registration/views.py:588
#: registration/views.py:609
msgid "The payment has been refused."
msgstr "Le paiement a été refusé."
#: registration/views.py:591
#: registration/views.py:612
#, python-brace-format
msgid "The return code is unknown: {code}"
msgstr "Le code de retour est inconnu : {code}"
#: registration/views.py:594
#: registration/views.py:615
#, python-brace-format
msgid "The return type is unknown: {type}"
msgstr "Le type de retour est inconnu : {type}"
#: registration/views.py:601
#: registration/views.py:622
msgid ""
"The payment has been successfully validated! Your registration is now "
"complete."
@ -2687,7 +2699,7 @@ msgstr ""
"Le paiement a été validé avec succès ! Votre inscription est désormais "
"complète."
#: registration/views.py:606
#: registration/views.py:627
msgid ""
"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 "
@ -2697,27 +2709,27 @@ msgstr ""
"quelques minutes, et sera faite automatiquement. Si ce n'est pas le cas, "
"merci de nous contacter."
#: registration/views.py:641
#: registration/views.py:662
#, python-brace-format
msgid "Photo authorization of {student}.{ext}"
msgstr "Autorisation de droit à l'image de {student}.{ext}"
#: registration/views.py:665
#: registration/views.py:686
#, python-brace-format
msgid "Health sheet of {student}.{ext}"
msgstr "Fiche sanitaire de {student}.{ext}"
#: registration/views.py:689
#: registration/views.py:710
#, python-brace-format
msgid "Vaccine sheet of {student}.{ext}"
msgstr "Carnet de vaccination de {student}.{ext}"
#: registration/views.py:713
#: registration/views.py:734
#, python-brace-format
msgid "Parental authorization of {student}.{ext}"
msgstr "Autorisation parentale de {student}.{ext}"
#: registration/views.py:736
#: registration/views.py:757
#, python-brace-format
msgid "Payment receipt of {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}"
def get_random_token():
return get_random_string(32)
class Payment(models.Model):
registrations = models.ManyToManyField(
ParticipantRegistration,
@ -526,6 +530,13 @@ class Payment(models.Model):
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,
@ -585,13 +596,13 @@ class Payment(models.Model):
return Tournament.final_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:
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):
checkout_intent = self.get_checkout_intent()
checkout_intent = self.get_checkout_intent(none_if_link_disabled=True)
if checkout_intent is not None:
return checkout_intent

View File

@ -79,17 +79,39 @@
<div class="card-body">
<div class="tab-content" id="payment-form">
<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 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.
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">
<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
</a>
</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 class="tab-pane fade" id="bank-transfer" role="tabpanel" aria-labelledby="bank-transfer-tab">
@ -138,5 +160,28 @@
elem => elem.addEventListener(
'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>
{% endblock %}

View File

@ -7,7 +7,7 @@ from tempfile import mkdtemp
from django.conf import settings
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.sites.models import Site
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,)))
class PaymenRedirectHelloAssoView(LoginRequiredMixin, DetailView):
class PaymenRedirectHelloAssoView(AccessMixin, DetailView):
model = Payment
def dispatch(self, request, *args, **kwargs):
if not self.request.user.is_authenticated or \
not self.request.user.registration.is_admin \
and (self.request.user.registration not in self.get_object().registrations.all()
or self.get_object().valid is not False):
payment = self.get_object()
# An external user has the link for the payment
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()
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)
def get(self, request, *args, **kwargs):
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"])
@ -558,9 +578,10 @@ class PaymentHelloAssoReturnView(DetailView):
def get(self, request, *args, **kwargs):
checkout_id = request.GET.get("checkoutIntentId")
payment = Payment.objects.get(checkout_intent_id=checkout_id).exclude(valid=True)
if payment != self.get_object():
messages.error(request, _("The payment is not found or is already validated."))
payment = self.get_object()
payment_qs = Payment.objects.exclude(valid=True).filter(checkout_intent_id=checkout_id).filter(pk=payment.pk)
if not payment_qs.exists():
messages.error(request, _("The payment is not found or is already validated."), "danger")
return redirect("index")
team = payment.team
@ -580,18 +601,18 @@ class PaymentHelloAssoReturnView(DetailView):
return_type = request.GET.get("type")
if return_type == "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
elif return_type == "return":
code = request.GET.get("code")
if code == "refused":
messages.error(request, _("The payment has been refused."))
messages.error(request, _("The payment has been refused."), "danger")
return error_response
elif code != "success":
messages.error(request, format_lazy(_("The return code is unknown: {code}"), code=code))
elif code != "succeeded":
messages.error(request, format_lazy(_("The return code is unknown: {code}"), code=code), "danger")
return error_response
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
checkout_intent = payment.get_checkout_intent()
@ -608,7 +629,7 @@ class PaymentHelloAssoReturnView(DetailView):
"and will be automatically done. "
"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,))
elif right_to_see:
success_response = redirect("participation:team_detail", args=(team.pk,))