Ce serveur Gitlab sera éteint le 30 juin 2020, pensez à migrer vos projets vers les serveurs gitlab-research.centralesupelec.fr et gitlab-student.centralesupelec.fr !

Commit 3dd87c94 authored by chirac's avatar chirac

Merge branch 'online_payement' into 'master'

Online payement

See merge request federez/re2o!68
parents 891014ba f033add0
......@@ -5,3 +5,4 @@ re2o.png
__pycache__/*
static_files/*
static/logo/*
media/*
......@@ -26,9 +26,8 @@ importé par les views.
Permet de créer une nouvelle facture pour un user (NewFactureForm),
et de l'editer (soit l'user avec EditFactureForm,
soit le trésorier avec TrezEdit qui a plus de possibilités que self
notamment sur le controle trésorier)
SelectArticleForm est utilisée lors de la creation d'une facture en
notamment sur le controle trésorier SelectArticleForm est utilisée
lors de la creation d'une facture en
parrallèle de NewFacture pour le choix des articles désirés.
(la vue correspondante est unique)
......@@ -40,8 +39,10 @@ from __future__ import unicode_literals
from django import forms
from django.db.models import Q
from django.forms import ModelForm, Form
from django.core.validators import MinValueValidator
from django.core.validators import MinValueValidator,MaxValueValidator
from .models import Article, Paiement, Facture, Banque
from preferences.models import OptionalUser
from users.models import User
from re2o.field_permissions import FieldPermissionFormMixin
......@@ -246,3 +247,58 @@ class DelBanqueForm(Form):
self.fields['banques'].queryset = instances
else:
self.fields['banques'].queryset = Banque.objects.all()
class NewFactureSoldeForm(NewFactureForm):
"""Creation d'une facture, moyen de paiement, banque et numero
de cheque"""
def __init__(self, *args, **kwargs):
prefix = kwargs.pop('prefix', self.Meta.model.__name__)
self.fields['cheque'].required = False
self.fields['banque'].required = False
self.fields['cheque'].label = 'Numero de chèque'
self.fields['banque'].empty_label = "Non renseigné"
self.fields['paiement'].empty_label = "Séléctionner\
une bite de paiement"
paiement_list = Paiement.objects.filter(type_paiement=1)
if paiement_list:
self.fields['paiement'].widget\
.attrs['data-cheque'] = paiement_list.first().id
class Meta:
model = Facture
fields = ['paiement', 'banque']
def clean(self):
cleaned_data = super(NewFactureSoldeForm, self).clean()
paiement = cleaned_data.get("paiement")
cheque = cleaned_data.get("cheque")
banque = cleaned_data.get("banque")
if not paiement:
raise forms.ValidationError("Le moyen de paiement est obligatoire")
elif paiement.type_paiement == "check" and not (cheque and banque):
raise forms.ValidationError("Le numéro de chèque et\
la banque sont obligatoires.")
return cleaned_data
class RechargeForm(Form):
value = forms.FloatField(
label='Valeur',
min_value=0.01,
validators = []
)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
super(RechargeForm, self).__init__(*args, **kwargs)
def clean_value(self):
value = self.cleaned_data['value']
options, _created = OptionalUser.objects.get_or_create()
if value < options.min_online_payment:
raise forms.ValidationError("Montant inférieur au montant minimal de paiement en ligne (%s) €" % options.min_online_payment)
if value + self.user.solde > options.max_solde:
raise forms.ValidationError("Le solde ne peux excéder %s " % options.max_solde)
return value
......@@ -289,7 +289,7 @@ class Vente(models.Model):
if not user_request.has_perm('cotisations.change_vente'):
return False, u"Vous n'avez pas le droit d'éditer les ventes"
elif not user_request.has_perm('cotisations.change_all_facture') and not self.facture.user.can_edit(user_request, *args, **kwargs)[0]:
return False, u"Vous ne pouvez pas éditer les factures de cet user protégé"
return False, u"Vous ne pouvez pas éditer les factures de cet user protégé"
elif not user_request.has_perm('cotisations.change_all_vente') and\
(self.facture.control or not self.facture.valid):
return False, u"Vous n'avez pas le droit d'éditer une vente\
......
"""Payment
Here are defined some views dedicated to online payement.
"""
from django.urls import reverse
from django.shortcuts import redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.views.decorators.csrf import csrf_exempt
from django.utils.datastructures import MultiValueDictKeyError
from django.http import HttpResponse, HttpResponseBadRequest
from collections import OrderedDict
from preferences.models import AssoOption
from .models import Facture
from .payment_utils.comnpay import Payment as ComnpayPayment
@csrf_exempt
@login_required
def accept_payment(request, factureid):
facture = get_object_or_404(Facture, id=factureid)
messages.success(
request,
"Le paiement de {} € a été accepté.".format(facture.prix())
)
return redirect(reverse('users:profil', kwargs={'userid':request.user.id}))
@csrf_exempt
@login_required
def refuse_payment(request):
messages.error(
request,
"Le paiement a été refusé."
)
return redirect(reverse('users:profil', kwargs={'userid':request.user.id}))
@csrf_exempt
def ipn(request):
option, _created = AssoOption.objects.get_or_create()
p = ComnpayPayment()
order = ('idTpe', 'idTransaction', 'montant', 'result', 'sec', )
try:
data = OrderedDict([(f, request.POST[f]) for f in order])
except MultiValueDictKeyError:
return HttpResponseBadRequest("HTTP/1.1 400 Bad Request")
if not p.validSec(data, option.payment_pass):
return HttpResponseBadRequest("HTTP/1.1 400 Bad Request")
result = True if (request.POST['result'] == 'OK') else False
idTpe = request.POST['idTpe']
idTransaction = request.POST['idTransaction']
# On vérifie que le paiement nous est destiné
if not idTpe == option.payment_id:
return HttpResponseBadRequest("HTTP/1.1 400 Bad Request")
try:
factureid = int(idTransaction)
except ValueError:
return HttpResponseBadRequest("HTTP/1.1 400 Bad Request")
facture = get_object_or_404(Facture, id=factureid)
# On vérifie que le paiement est valide
if not result:
# Le paiement a échoué : on effectue les actions nécessaires (On indique qu'elle a échoué)
facture.delete()
# On notifie au serveur ComNPay qu'on a reçu les données pour traitement
return HttpResponse("HTTP/1.1 200 OK")
facture.valid = True
facture.save()
# A nouveau, on notifie au serveur qu'on a bien traité les données
return HttpResponse("HTTP/1.0 200 OK")
def comnpay(facture, request):
host = request.get_host()
option, _created = AssoOption.objects.get_or_create()
p = ComnpayPayment(
str(option.payment_id),
str(option.payment_pass),
'https://' + host + reverse(
'cotisations:accept_payment',
kwargs={'factureid':facture.id}
),
'https://' + host + reverse('cotisations:refuse_payment'),
'https://' + host + reverse('cotisations:ipn'),
"",
"D"
)
r = {
'action' : 'https://secure.homologation.comnpay.com',
'method' : 'POST',
'content' : p.buildSecretHTML(
"Rechargement du solde",
facture.prix(),
idTransaction=str(facture.id)
),
'amount' : facture.prix,
}
return r
PAYMENT_SYSTEM = {
'COMNPAY' : comnpay,
'NONE' : None
}
import time
from random import randrange
import base64
import hashlib
from collections import OrderedDict
from itertools import chain
class Payment():
vad_number = ""
secret_key = ""
urlRetourOK = ""
urlRetourNOK = ""
urlIPN = ""
source = ""
typeTr = "D"
def __init__(self, vad_number = "", secret_key = "", urlRetourOK = "", urlRetourNOK = "", urlIPN = "", source="", typeTr="D"):
self.vad_number = vad_number
self.secret_key = secret_key
self.urlRetourOK = urlRetourOK
self.urlRetourNOK = urlRetourNOK
self.urlIPN = urlIPN
self.source = source
self.typeTr = typeTr
def buildSecretHTML(self, produit="Produit", montant="0.00", idTransaction=""):
if idTransaction == "":
self.idTransaction = str(time.time())+self.vad_number+str(randrange(999))
else:
self.idTransaction = idTransaction
array_tpe = OrderedDict(
montant= str(montant),
idTPE= self.vad_number,
idTransaction= self.idTransaction,
devise= "EUR",
lang= 'fr',
nom_produit= produit,
source= self.source,
urlRetourOK= self.urlRetourOK,
urlRetourNOK= self.urlRetourNOK,
typeTr= str(self.typeTr)
)
if self.urlIPN!="":
array_tpe['urlIPN'] = self.urlIPN
array_tpe['key'] = self.secret_key;
strWithKey = base64.b64encode(bytes('|'.join(array_tpe.values()), 'utf-8'))
del array_tpe["key"]
array_tpe['sec'] = hashlib.sha512(strWithKey).hexdigest()
ret = ""
for key in array_tpe:
ret += '<input type="hidden" name="'+key+'" value="'+array_tpe[key]+'"/>'
return ret
def validSec(self, values, secret_key):
if "sec" in values:
sec = values['sec']
del values["sec"]
strWithKey = hashlib.sha512(base64.b64encode(bytes('|'.join(values.values()) +"|"+secret_key, 'utf-8'))).hexdigest()
return strWithKey.upper() == sec.upper()
else:
return False
......@@ -34,6 +34,9 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<form class="form" method="post">
{% csrf_token %}
<h3>Nouvelle facture</h3>
<p>
Solde de l'utilisateur : {{ user.solde }} €
</p>
{% bootstrap_form factureform %}
{{ venteform.management_form }}
<!-- TODO: FIXME to include data-type="check" for right option in id_cheque select -->
......
{% extends "cotisations/sidebar.html" %}
{% comment %}
Re2o est un logiciel d'administration développé initiallement au rezometz. Il
se veut agnostique au réseau considéré, de manière à être installable en
quelques clics.
Copyright © 2017 Gabriel Détraz
Copyright © 2017 Goulven Kermarec
Copyright © 2017 Augustin Lemesle
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
{% endcomment %}
{% load bootstrap3 %}
{% load staticfiles%}
{% block title %}Création et modification de factures{% endblock %}
{% block content %}
{% bootstrap_form_errors venteform.management_form %}
<form class="form" method="post">
{% csrf_token %}
<h3>Nouvelle facture</h3>
{{ venteform.management_form }}
<!-- TODO: FIXME to include data-type="check" for right option in id_cheque select -->
<h3>Articles de la facture</h3>
<div id="form_set" class="form-group">
{% for form in venteform.forms %}
<div class='product_to_sell form-inline'>
Article : &nbsp;
{% bootstrap_form form label_class='sr-only' %}
&nbsp;
<button class="btn btn-danger btn-sm"
id="id_form-0-article-remove" type="button">
<span class="glyphicon glyphicon-remove"></span>
</button>
</div>
{% endfor %}
</div>
<input class="btn btn-primary btn-sm" role="button" value="Ajouter un article" id="add_one">
<p>
Prix total : <span id="total_price">0,00</span>
</p>
{% bootstrap_button "Créer ou modifier" button_type="submit" icon="star" %}
</form>
<script type="text/javascript">
var prices = {};
{% for article in articlelist %}
prices[{{ article.id|escapejs }}] = {{ article.prix }};
{% endfor %}
var template = `Article : &nbsp;
{% bootstrap_form venteform.empty_form label_class='sr-only' %}
&nbsp;
<button class="btn btn-danger btn-sm"
id="id_form-__prefix__-article-remove" type="button">
<span class="glyphicon glyphicon-remove"></span>
</button>`
function add_article(){
// Index start at 0 => new_index = number of items
var new_index =
document.getElementsByClassName('product_to_sell').length;
document.getElementById('id_form-TOTAL_FORMS').value ++;
var new_article = document.createElement('div');
new_article.className = 'product_to_sell form-inline';
new_article.innerHTML = template.replace(/__prefix__/g, new_index);
document.getElementById('form_set').appendChild(new_article);
add_listenner_for_id(new_index);
}
function update_price(){
var price = 0;
var product_count =
document.getElementsByClassName('product_to_sell').length;
var article, article_price, quantity;
for (i = 0; i < product_count; ++i){
article = document.getElementById(
'id_form-' + i.toString() + '-article').value;
if (article == '') {
continue;
}
article_price = prices[article];
quantity = document.getElementById(
'id_form-' + i.toString() + '-quantity').value;
price += article_price * quantity;
}
document.getElementById('total_price').innerHTML =
price.toFixed(2).toString().replace('.', ',');
}
function add_listenner_for_id(i){
document.getElementById('id_form-' + i.toString() + '-article')
.addEventListener("change", update_price, true);
document.getElementById('id_form-' + i.toString() + '-article')
.addEventListener("onkeypress", update_price, true);
document.getElementById('id_form-' + i.toString() + '-quantity')
.addEventListener("change", update_price, true);
document.getElementById('id_form-' + i.toString() + '-article-remove')
.addEventListener("click", function(event) {
var article = event.target.parentNode;
article.parentNode.removeChild(article);
document.getElementById('id_form-TOTAL_FORMS').value --;
update_price();
}
)
}
function set_cheque_info_visibility() {
var paiement = document.getElementById("id_Facture-paiement");
var visible = paiement.value == paiement.getAttribute('data-cheque');
p = document.getElementById("id_Facture-paiement");
var display = 'none';
if (visible) {
display = 'block';
}
document.getElementById("id_Facture-cheque")
.parentNode.style.display = display;
document.getElementById("id_Facture-banque")
.parentNode.style.display = display;
}
// Add events manager when DOM is fully loaded
document.addEventListener("DOMContentLoaded", function() {
document.getElementById("add_one")
.addEventListener("click", add_article, true);
var product_count =
document.getElementsByClassName('product_to_sell').length;
for (i = 0; i < product_count; ++i){
add_listenner_for_id(i);
}
document.getElementById("id_Facture-paiement")
.addEventListener("change", set_cheque_info_visibility, true);
set_cheque_info_visibility();
update_price();
});
</script>
{% endblock %}
{% extends "cotisations/sidebar.html" %}
{% comment %}
Re2o est un logiciel d'administration développé initiallement au rezometz. Il
se veut agnostique au réseau considéré, de manière à être installable en
quelques clics.
Copyright © 2017 Gabriel Détraz
Copyright © 2017 Goulven Kermarec
Copyright © 2017 Augustin Lemesle
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
{% endcomment %}
{% load bootstrap3 %}
{% load staticfiles%}
{% block title %}Rechargement du solde{% endblock %}
{% block content %}
<h3>Recharger de {{ amount }} €</h3>
<form class="form" method="{{ method }}" action="{{action}}">
{{ content | safe }}
{% bootstrap_button "Payer" button_type="submit" icon="piggy-bank" %}
</form>
{% endblock %}
{% extends "cotisations/sidebar.html" %}
{% comment %}
Re2o est un logiciel d'administration développé initiallement au rezometz. Il
se veut agnostique au réseau considéré, de manière à être installable en
quelques clics.
Copyright © 2017 Gabriel Détraz
Copyright © 2017 Goulven Kermarec
Copyright © 2017 Augustin Lemesle
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
{% endcomment %}
{% load bootstrap3 %}
{% load staticfiles%}
{% block title %}Rechargement du solde{% endblock %}
{% block content %}
<h2>Rechargement du solde</h2>
<h3>Solde : <span class="label label-default">{{ request.user.solde }} €</span></h3>
<form class="form" method="post">
{% csrf_token %}
{% bootstrap_form rechargeform %}
{% bootstrap_button "Valider" button_type="submit" icon="piggy-bank" %}
</form>
{% endblock %}
......@@ -26,6 +26,7 @@ from django.conf.urls import url
import re2o
from . import views
from . import payment
urlpatterns = [
url(r'^new_facture/(?P<userid>[0-9]+)$',
......@@ -110,5 +111,25 @@ urlpatterns = [
views.control,
name='control'
),
url(r'^new_facture_solde/(?P<userid>[0-9]+)$',
views.new_facture_solde,
name='new_facture_solde'
),
url(r'^recharge/$',
views.recharge,
name='recharge'
),
url(r'^payment/accept/(?P<factureid>[0-9]+)$',
payment.accept_payment,
name='accept_payment'
),
url(r'^payment/refuse/$',
payment.refuse_payment,
name='refuse_payment'
),
url(r'^payment/ipn/$',
payment.ipn,
name='ipn'
),
url(r'^$', views.index, name='index'),
]
......@@ -29,6 +29,7 @@ import os
from django.urls import reverse
from django.shortcuts import render, redirect
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.core.validators import MaxValueValidator
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib import messages
from django.db.models import ProtectedError
......@@ -36,6 +37,8 @@ from django.db import transaction
from django.db.models import Q
from django.forms import modelformset_factory, formset_factory
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.debug import sensitive_variables
from reversion import revisions as reversion
from reversion.models import Version
# Import des models, forms et fonctions re2o
......@@ -67,8 +70,11 @@ from .forms import (
NewFactureFormPdf,
SelectUserArticleForm,
SelectClubArticleForm,
CreditSoldeForm
CreditSoldeForm,
NewFactureSoldeForm,
RechargeForm
)
from . import payment
from .tex import render_invoice
......@@ -584,3 +590,127 @@ def index(request):
return render(request, 'cotisations/index.html', {
'facture_list': facture_list
})
@login_required
def new_facture_solde(request, userid):
"""Creation d'une facture pour un user. Renvoie la liste des articles
et crée des factures dans un formset. Utilise un peu de js coté template
pour ajouter des articles.
Parse les article et boucle dans le formset puis save les ventes,
enfin sauve la facture parente.
TODO : simplifier cette fonction, déplacer l'intelligence coté models
Facture et Vente."""
user = request.user
facture = Facture(user=user)
paiement, _created = Paiement.objects.get_or_create(moyen='Solde')
facture.paiement = paiement
# Le template a besoin de connaitre les articles pour le js
article_list = Article.objects.filter(
Q(type_user='All') | Q(type_user=request.user.class_name)
)
if request.user.is_class_club:
article_formset = formset_factory(SelectClubArticleForm)(request.POST or None)
else:
article_formset = formset_factory(SelectUserArticleForm)(request.POST or None)
if article_formset.is_valid():
articles = article_formset
# Si au moins un article est rempli
if any(art.cleaned_data for art in articles):
options, _created = OptionalUser.objects.get_or_create()
user_solde = options.user_solde
solde_negatif = options.solde_negatif
# Si on paye par solde, que l'option est activée,
# on vérifie que le négatif n'est pas atteint
if user_solde:
prix_total = 0
for art_item in articles:
if art_item.cleaned_data:
prix_total += art_item.cleaned_data['article']\
.prix*art_item.cleaned_data['quantity']
if float(user.solde) - float(prix_total) < solde_negatif:
messages.error(request, "Le solde est insuffisant pour\
effectuer l'opération")
return redirect(reverse(
'users:profil',
kwargs={'userid': userid}
))
with transaction.atomic(), reversion.create_revision():
facture.save()
reversion.set_user(request.user)
reversion.set_comment("Création")
for art_item in articles:
if art_item.cleaned_data:
article = art_item.cleaned_data['article']
quantity = art_item.cleaned_data['quantity']
new_vente = Vente.objects.create(
facture=facture,
name=article.name,
prix=article.prix,
type_cotisation=article.type_cotisation,
duration=article.duration,
number=quantity
)
with transaction.atomic(), reversion.create_revision():
new_vente.save()
reversion.set_user(request.user)
reversion.set_comment("Création")
if any(art_item.cleaned_data['article'].type_cotisation
for art_item in articles if art_item.cleaned_data):
messages.success(
request,
"La cotisation a été prolongée\
pour l'adhérent %s jusqu'au %s" % (
user.pseudo, user.end_adhesion()
)
)
else:
messages.success(request, "La facture a été crée")
return redirect(reverse(
'users:profil',
kwargs={'userid': userid}
))
messages.error(
request,
u"Il faut au moins un article valide pour créer une facture"
)
return redirect(reverse(
'users:profil',
kwargs={'userid': userid}
))
return form({
'venteform': article_formset,
'articlelist': article_list
}, 'cotisations/new_facture_solde.html', request)
@login_required
def recharge(request):
options, _created = AssoOption.objects.get_or_create()
if options.payment == 'NONE':
messages.error(
request,
"Le paiement en ligne est désactivé."
)
return redirect(reverse(
'users:profil',
kwargs={'userid': request.user.id}
))
f = RechargeForm(request.POST or None, user=request.user)
if f.is_valid():
facture = Facture(user=request.user)