diff --git a/cotisations/migrations/0032_chequepayment_comnpaypayment.py b/cotisations/migrations/0032_chequepayment_comnpaypayment.py new file mode 100644 index 00000000..76fc4ea0 --- /dev/null +++ b/cotisations/migrations/0032_chequepayment_comnpaypayment.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-06-28 17:28 +from __future__ import unicode_literals + +import cotisations.payment_methods.comnpay.aes_field +from django.db import migrations, models +import django.db.models.deletion + + +def add_cheque(apps, schema_editor): + ChequePayment = apps.get_model('cotisations', 'ChequePayment') + Payment = apps.get_model('cotisations', 'Paiement') + for p in Payment.objects.filter(type_paiement=1): + cheque = ChequePayment() + cheque.payment = p + cheque.save() + + +def add_comnpay(apps, schema_editor): + ComnpayPayment = apps.get_model('cotisations', 'ComnpayPayment') + Payment = apps.get_model('cotisations', 'Paiement') + AssoOption = apps.get_model('preferences', 'AssoOption') + options, _created = AssoOption.objects.get_or_create() + payment, _created = Payment.objects.get_or_create( + moyen='Rechargement en ligne' + ) + comnpay = ComnpayPayment() + comnpay.payment_user = options.payment_id + comnpay.payment_pass = options.payment_pass + comnpay.payment = payment + comnpay.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0031_article_allow_self_subscription'), + ] + + operations = [ + migrations.CreateModel( + name='ChequePayment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('payment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='payment_method', to='cotisations.Paiement')), + ], + ), + migrations.CreateModel( + name='ComnpayPayment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('payment_user', models.CharField(blank=True, default='', max_length=255)), + ('payment_pass', cotisations.payment_methods.comnpay.aes_field.AESEncryptedField(blank=True, max_length=255, null=True)), + ('payment', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='payment_method', to='cotisations.Paiement')), + ], + ), + migrations.RunPython(add_cheque), + migrations.RunPython(add_comnpay), + ] diff --git a/cotisations/migrations/0033_auto_20180628_2157.py b/cotisations/migrations/0033_auto_20180628_2157.py new file mode 100644 index 00000000..51509105 --- /dev/null +++ b/cotisations/migrations/0033_auto_20180628_2157.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-06-28 19:57 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0032_chequepayment_comnpaypayment'), + ] + + operations = [ + migrations.AlterField( + model_name='comnpaypayment', + name='payment_id', + field=models.CharField(blank=True, default='', max_length=255), + ), + ] diff --git a/cotisations/models.py b/cotisations/models.py index fe741d8f..d91aa6f1 100644 --- a/cotisations/models.py +++ b/cotisations/models.py @@ -42,6 +42,9 @@ from django.core.validators import MinValueValidator from django.utils import timezone from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _l +from django.urls import reverse +from django.shortcuts import redirect +from django.contrib import messages from machines.models import regen from re2o.field_permissions import FieldPermissionModelMixin @@ -629,6 +632,36 @@ class Paiement(RevMixin, AclMixin, models.Model): ) super(Paiement, self).save(*args, **kwargs) + def end_payment(self, invoice, request): + """ + The general way of ending a payment. You may redefine this method for custom + payment methods. Must return a HttpResponse-like object. + """ + if hasattr(self, 'payment_method'): + return self.payment_method.end_payment(invoice, request) + + # In case a cotisation was bought, inform the user, the + # cotisation time has been extended too + if any(sell.type_cotisation for sell in invoice.vente_set.all()): + messages.success( + request, + _("The cotisation of %(member_name)s has been \ + extended to %(end_date)s.") % { + 'member_name': request.user.pseudo, + 'end_date': request.user.end_adhesion() + } + ) + # Else, only tell the invoice was created + else: + messages.success( + request, + _("The invoice has been created.") + ) + return redirect(reverse( + 'users:profil', + kwargs={'userid': request.user.pk} + )) + class Cotisation(RevMixin, AclMixin, models.Model): """ diff --git a/cotisations/payment_methods/__init__.py b/cotisations/payment_methods/__init__.py new file mode 100644 index 00000000..cc21f42b --- /dev/null +++ b/cotisations/payment_methods/__init__.py @@ -0,0 +1,9 @@ +from django.conf.urls import include, url + +from . import comnpay, cheque + + +urlpatterns = [ + url(r'^comnpay/', include(comnpay.urls, namespace='comnpay')), + url(r'^cheque/', include(cheque.urls, namespace='cheque')), +] diff --git a/cotisations/payment_methods/cheque/__init__.py b/cotisations/payment_methods/cheque/__init__.py new file mode 100644 index 00000000..6610cdd8 --- /dev/null +++ b/cotisations/payment_methods/cheque/__init__.py @@ -0,0 +1,7 @@ +""" +This module contains a method to pay online using cheque. +""" +from . import models, urls, views +NAME = "CHEQUE" + +Payment = models.ChequePayment diff --git a/cotisations/payment_methods/cheque/forms.py b/cotisations/payment_methods/cheque/forms.py new file mode 100644 index 00000000..892ba190 --- /dev/null +++ b/cotisations/payment_methods/cheque/forms.py @@ -0,0 +1,10 @@ +from django import forms +from django.utils.translation import ugettext_lazy as _l + +from cotisations.models import Banque as Bank + + +class ChequeForm(forms.Form): + """A simple form to get the bank a the cheque number.""" + bank = forms.ModelChoiceField(Bank.objects.all(), label=_l("Bank")) + number = forms.CharField(label=_l("Cheque number")) diff --git a/cotisations/payment_methods/cheque/models.py b/cotisations/payment_methods/cheque/models.py new file mode 100644 index 00000000..336f025e --- /dev/null +++ b/cotisations/payment_methods/cheque/models.py @@ -0,0 +1,21 @@ +from django.db import models +from django.shortcuts import redirect +from django.urls import reverse + +from cotisations.models import Paiement as BasePayment + + +class ChequePayment(models.Model): + """ + The model allowing you to pay with a cheque. It redefines post_payment + method. See `cotisations.models.Paiement for further details. + """ + payment = models.OneToOneField(BasePayment, related_name='payment_method') + + def end_payment(self, invoice, request): + invoice.valid = False + invoice.save() + return redirect(reverse( + 'cotisations:cheque:validate', + kwargs={'invoice_pk': invoice.pk} + )) diff --git a/cotisations/payment_methods/cheque/urls.py b/cotisations/payment_methods/cheque/urls.py new file mode 100644 index 00000000..f7cc39d5 --- /dev/null +++ b/cotisations/payment_methods/cheque/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import url +from . import views + +urlpatterns = [ + url( + r'^validate/(?P[0-9]+)$', + views.cheque, + name='validate' + ) +] diff --git a/cotisations/payment_methods/cheque/views.py b/cotisations/payment_methods/cheque/views.py new file mode 100644 index 00000000..ee858c9b --- /dev/null +++ b/cotisations/payment_methods/cheque/views.py @@ -0,0 +1,45 @@ +"""Payment + +Here are defined some views dedicated to cheque payement. +""" + +from django.urls import reverse +from django.shortcuts import redirect, render, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.utils.translation import ugettext as _ + +from cotisations.models import Facture as Invoice + +from .models import ChequePayment +from .forms import ChequeForm + + +@login_required +def cheque(request, invoice_pk): + invoice = get_object_or_404(Invoice, pk=invoice_pk) + payment_method = getattr(invoice.paiement, 'payment_method', None) + if invoice.valid or not isinstance(payment_method, ChequePayment): + messages.error( + request, + _("You cannot pay this invoice with a cheque.") + ) + return redirect(reverse( + 'users:profil', + kwargs={'userid': request.user.pk} + )) + form = ChequeForm(request.POST or None) + if form.is_valid(): + invoice.banque = form.cleaned_data['bank'] + invoice.cheque = form.cleaned_data['number'] + invoice.valid = True + invoice.save() + return redirect(reverse( + 'users:profil', + kwargs={'userid': request.user.pk} + )) + return render( + request, + 'cotisations/payment_form.html', + {'form': form} + ) diff --git a/cotisations/payment_methods/comnpay/__init__.py b/cotisations/payment_methods/comnpay/__init__.py new file mode 100644 index 00000000..d0289364 --- /dev/null +++ b/cotisations/payment_methods/comnpay/__init__.py @@ -0,0 +1,6 @@ +""" +This module contains a method to pay online using comnpay. +""" +from . import models, urls, views +NAME = "COMNPAY" +Payment = models.ComnpayPayment diff --git a/cotisations/payment_methods/comnpay/aes_field.py b/cotisations/payment_methods/comnpay/aes_field.py new file mode 100644 index 00000000..1329b0a7 --- /dev/null +++ b/cotisations/payment_methods/comnpay/aes_field.py @@ -0,0 +1,94 @@ +# coding:utf-8 +# 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 +# Copyright © 2018 Maël Kervella +# +# 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. + +# App de gestion des machines pour re2o +# Gabriel Détraz, Augustin Lemesle +# Gplv2 +"""preferences.aes_field +Module defining a AESEncryptedField object that can be used in forms +to handle the use of properly encrypting and decrypting AES keys +""" + +import string +import binascii +from random import choice +from Crypto.Cipher import AES + +from django.db import models +from django.conf import settings + +EOD = '`%EofD%`' # This should be something that will not occur in strings + + +def genstring(length=16, chars=string.printable): + """ Generate a random string of length `length` and composed of + the characters in `chars` """ + return ''.join([choice(chars) for i in range(length)]) + + +def encrypt(key, s): + """ AES Encrypt a secret `s` with the key `key` """ + obj = AES.new(key) + datalength = len(s) + len(EOD) + if datalength < 16: + saltlength = 16 - datalength + else: + saltlength = 16 - datalength % 16 + ss = ''.join([s, EOD, genstring(saltlength)]) + return obj.encrypt(ss) + + +def decrypt(key, s): + """ AES Decrypt a secret `s` with the key `key` """ + obj = AES.new(key) + ss = obj.decrypt(s) + return ss.split(bytes(EOD, 'utf-8'))[0] + + +class AESEncryptedField(models.CharField): + """ A Field that can be used in forms for adding the support + of AES ecnrypted fields """ + def save_form_data(self, instance, data): + setattr(instance, self.name, + binascii.b2a_base64(encrypt(settings.AES_KEY, data))) + + def to_python(self, value): + if value is None: + return None + return decrypt(settings.AES_KEY, + binascii.a2b_base64(value)).decode('utf-8') + + def from_db_value(self, value, *args, **kwargs): + if value is None: + return value + return decrypt(settings.AES_KEY, + binascii.a2b_base64(value)).decode('utf-8') + + def get_prep_value(self, value): + if value is None: + return value + return binascii.b2a_base64(encrypt( + settings.AES_KEY, + value + )) diff --git a/cotisations/payment_utils/comnpay.py b/cotisations/payment_methods/comnpay/comnpay.py similarity index 99% rename from cotisations/payment_utils/comnpay.py rename to cotisations/payment_methods/comnpay/comnpay.py index 6c73463f..272ab928 100644 --- a/cotisations/payment_utils/comnpay.py +++ b/cotisations/payment_methods/comnpay/comnpay.py @@ -10,7 +10,7 @@ import hashlib from collections import OrderedDict -class Payment(): +class Transaction(): """ The class representing a transaction with all the functions used during the negociation """ diff --git a/cotisations/payment_methods/comnpay/models.py b/cotisations/payment_methods/comnpay/models.py new file mode 100644 index 00000000..83a6f534 --- /dev/null +++ b/cotisations/payment_methods/comnpay/models.py @@ -0,0 +1,30 @@ +from django.db import models +from django.shortcuts import render + +from cotisations.models import Paiement as BasePayment + +from .aes_field import AESEncryptedField +from .views import comnpay + + +class ComnpayPayment(models.Model): + """ + The model allowing you to pay with COMNPAY. It redefines post_payment + method. See `cotisations.models.Paiement for further details. + """ + payment = models.OneToOneField(BasePayment, related_name='payment_method') + + payment_credential = models.CharField( + max_length=255, + default='', + blank=True + ) + payment_pass = AESEncryptedField( + max_length=255, + null=True, + blank=True, + ) + + def end_payment(self, invoice, request): + content = comnpay(invoice, request) + return render(request, 'cotisations/payment.html', content) diff --git a/cotisations/payment_methods/comnpay/urls.py b/cotisations/payment_methods/comnpay/urls.py new file mode 100644 index 00000000..d7b1e293 --- /dev/null +++ b/cotisations/payment_methods/comnpay/urls.py @@ -0,0 +1,20 @@ +from django.conf.urls import url +from . import views + +urlpatterns = [ + url( + r'^accept/(?P[0-9]+)$', + views.accept_payment, + name='accept_payment' + ), + url( + r'^refuse/$', + views.refuse_payment, + name='refuse_payment' + ), + url( + r'^ipn/$', + views.ipn, + name='ipn' + ), +] diff --git a/cotisations/payment.py b/cotisations/payment_methods/comnpay/views.py similarity index 91% rename from cotisations/payment.py rename to cotisations/payment_methods/comnpay/views.py index 4749bd05..094ebff4 100644 --- a/cotisations/payment.py +++ b/cotisations/payment_methods/comnpay/views.py @@ -15,8 +15,8 @@ from django.utils.translation import ugettext as _ from django.http import HttpResponse, HttpResponseBadRequest from preferences.models import AssoOption -from .models import Facture -from .payment_utils.comnpay import Payment as ComnpayPayment +from cotisations.models import Facture +from .comnpay import Transaction @csrf_exempt @@ -73,7 +73,7 @@ def ipn(request): Verify that we can firmly save the user's action and notify Comnpay with 400 response if not or with a 200 response if yes """ - p = ComnpayPayment() + p = Transaction() order = ('idTpe', 'idTransaction', 'montant', 'result', 'sec', ) try: data = OrderedDict([(f, request.POST[f]) for f in order]) @@ -121,15 +121,15 @@ def comnpay(facture, request): the preferences. """ host = request.get_host() - p = ComnpayPayment( + p = Transaction( str(AssoOption.get_cached_value('payment_id')), str(AssoOption.get_cached_value('payment_pass')), 'https://' + host + reverse( - 'cotisations:accept_payment', + 'cotisations:comnpay_accept_payment', kwargs={'factureid': facture.id} ), - 'https://' + host + reverse('cotisations:refuse_payment'), - 'https://' + host + reverse('cotisations:ipn'), + 'https://' + host + reverse('cotisations:comnpay_refuse_payment'), + 'https://' + host + reverse('cotisations:comnpay_ipn'), "", "D" ) @@ -145,9 +145,3 @@ def comnpay(facture, request): } return r - -# The payment systems supported by re2o -PAYMENT_SYSTEM = { - 'COMNPAY': comnpay, - 'NONE': None -} diff --git a/cotisations/payment_utils/__init__.py b/cotisations/payment_utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cotisations/templates/cotisations/payment_form.html b/cotisations/templates/cotisations/payment_form.html new file mode 100644 index 00000000..1057b7cb --- /dev/null +++ b/cotisations/templates/cotisations/payment_form.html @@ -0,0 +1,37 @@ +{% 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 © 2018 Hugo Levy-Falk + +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%} +{% load i18n %} + +{% block title %}{% trans form.title %}{% endblock %} + +{% block content %} +

{% trans form.title %}

+
+ {% csrf_token %} + {% bootstrap_form form %} + {% bootstrap_button tr_confirm button_type='submit' icon='piggy-bank' %} +
+{% endblock %} diff --git a/cotisations/urls.py b/cotisations/urls.py index 6eafe721..0603c96e 100644 --- a/cotisations/urls.py +++ b/cotisations/urls.py @@ -29,7 +29,7 @@ from django.conf.urls import url import re2o from . import views -from . import payment +from . import payment_methods urlpatterns = [ url( @@ -143,20 +143,6 @@ urlpatterns = [ views.recharge, name='recharge' ), - url( - r'^payment/accept/(?P[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'), -] +] + payment_methods.urlpatterns + diff --git a/cotisations/views.py b/cotisations/views.py index 518df96b..47b716ce 100644 --- a/cotisations/views.py +++ b/cotisations/views.py @@ -73,7 +73,6 @@ from .forms import ( CreditSoldeForm, RechargeForm ) -from . import payment as online_payment from .tex import render_invoice @@ -159,11 +158,6 @@ def new_facture(request, user, userid): 'users:profil', kwargs={'userid': userid} )) - is_online_payment = new_invoice_instance.paiement == ( - Paiement.objects.get_or_create( - moyen='Rechargement en ligne')[0]) - new_invoice_instance.valid = not is_online_payment - # Saving the invoice new_invoice_instance.save() @@ -182,34 +176,8 @@ def new_facture(request, user, userid): ) new_purchase.save() - if is_online_payment: - content = online_payment.PAYMENT_SYSTEM[ - AssoOption.get_cached_value('payment') - ](new_invoice_instance, request) - return render(request, 'cotisations/payment.html', content) + return new_invoice_instance.paiement.end_payment(new_invoice_instance, request) - # In case a cotisation was bought, inform the user, the - # cotisation time has been extended too - if any(art_item.cleaned_data['article'].type_cotisation - for art_item in articles if art_item.cleaned_data): - messages.success( - request, - _("The cotisation of %(member_name)s has been \ - extended to %(end_date)s.") % { - 'member_name': user.pseudo, - 'end_date': user.end_adhesion() - } - ) - # Else, only tell the invoice was created - else: - messages.success( - request, - _("The invoice has been created.") - ) - return redirect(reverse( - 'users:profil', - kwargs={'userid': userid} - )) messages.error( request, _("You need to choose at least one article.") @@ -894,9 +862,9 @@ def recharge(request): number=1 ) purchase.save() - content = online_payment.PAYMENT_SYSTEM[ - AssoOption.get_cached_value('payment') - ](invoice, request) + # content = online_payment.PAYMENT_SYSTEM[ + # AssoOption.get_cached_value('payment') + # ](invoice, request) return render(request, 'cotisations/payment.html', content) return form({ 'rechargeform': refill_form, diff --git a/users/views.py b/users/views.py index fcb44f65..a125124c 100644 --- a/users/views.py +++ b/users/views.py @@ -882,7 +882,7 @@ def profil(request, users, **_kwargs): SortTable.USERS_INDEX_WHITE ) user_solde = OptionalUser.get_cached_value('user_solde') - allow_online_payment = AssoOption.get_cached_value('payment') != 'NONE' + allow_online_payment = True# TODO : AssoOption.get_cached_value('payment') != 'NONE' return render( request, 'users/profil.html',