diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb56d7c..1b9020e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,7 +95,6 @@ If you to restrict the IP which can see the debug, use the `INTERNAL_IPS` option INTERNAL_IPS = ["10.0.0.1", "10.0.0.2"] ``` - ## MR 145: Fix #117 : Use unix_name instead of name for ldap groups Fix a mixing between unix_name and name for groups @@ -110,3 +109,14 @@ After this modification you need to: ```bash sudo nslcd -i groups ``` + +## MR 174 : Fix online payment + allow users to pay their subscription + +Add the possibility to use custom payment methods. There is also a boolean field on the +Payments allowing every user to use some kinds of payment. You have to add the rights `cotisations.use_every_payment` and `cotisations.buy_every_article` +to the staff members so they can use every type of payment to buy anything. + +Don't forget to run migrations, several settings previously in the `preferences` app ar now +in their own Payment models. + +To have a closer look on how the payments works, please go to the wiki. diff --git a/cotisations/forms.py b/cotisations/forms.py index a6647a45..7ad9e413 100644 --- a/cotisations/forms.py +++ b/cotisations/forms.py @@ -5,6 +5,7 @@ # Copyright © 2017 Gabriel Détraz # Copyright © 2017 Goulven Kermarec # Copyright © 2017 Augustin Lemesle +# 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 @@ -41,75 +42,49 @@ from django.forms import ModelForm, Form from django.core.validators import MinValueValidator from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _l +from django.shortcuts import get_object_or_404 -from preferences.models import OptionalUser from re2o.field_permissions import FieldPermissionFormMixin from re2o.mixins import FormRevMixin from .models import Article, Paiement, Facture, Banque +from .payment_methods import balance -class NewFactureForm(FormRevMixin, ModelForm): +class FactureForm(FieldPermissionFormMixin, FormRevMixin, ModelForm): """ - Form used to create a new invoice by using a payment method, a bank and a - cheque number. + Form used to manage and create an invoice and its fields. """ - def __init__(self, *args, **kwargs): + + def __init__(self, *args, creation=False, **kwargs): + user = kwargs['user'] prefix = kwargs.pop('prefix', self.Meta.model.__name__) - super(NewFactureForm, self).__init__(*args, prefix=prefix, **kwargs) - # TODO : remove the use of cheque and banque and paiement - # for something more generic or at least in English - self.fields['cheque'].required = False - self.fields['banque'].required = False - self.fields['cheque'].label = _("Cheque number") - self.fields['banque'].empty_label = _("Not specified") + super(FactureForm, self).__init__(*args, prefix=prefix, **kwargs) self.fields['paiement'].empty_label = \ _("Select a payment method") - paiement_list = Paiement.objects.filter(type_paiement=1) - if paiement_list: - self.fields['paiement'].widget\ - .attrs['data-cheque'] = paiement_list.first().id + self.fields['paiement'].queryset = Paiement.find_allowed_payments(user) + if not creation: + self.fields['user'].label = _("Member") + self.fields['user'].empty_label = \ + _("Select the proprietary member") + self.fields['valid'].label = _("Validated invoice") + else: + self.fields = {'paiement': self.fields['paiement']} class Meta: model = Facture - fields = ['paiement', 'banque', 'cheque'] + fields = '__all__' def clean(self): - cleaned_data = super(NewFactureForm, self).clean() + cleaned_data = super(FactureForm, self).clean() paiement = cleaned_data.get('paiement') - cheque = cleaned_data.get('cheque') - banque = cleaned_data.get('banque') if not paiement: raise forms.ValidationError( _("A payment method must be specified.") ) - elif paiement.type_paiement == 'check' and not (cheque and banque): - raise forms.ValidationError( - _("A cheque number and a bank must be specified.") - ) return cleaned_data -class CreditSoldeForm(NewFactureForm): - """ - Form used to make some operations on the user's balance if the option is - activated. - """ - class Meta(NewFactureForm.Meta): - model = Facture - fields = ['paiement', 'banque', 'cheque'] - - def __init__(self, *args, **kwargs): - super(CreditSoldeForm, self).__init__(*args, **kwargs) - # TODO : change solde to balance - self.fields['paiement'].queryset = Paiement.objects.exclude( - moyen='solde' - ).exclude(moyen='Solde') - - montant = forms.DecimalField(max_digits=5, decimal_places=2, required=True) - - -class SelectUserArticleForm( - FormRevMixin, Form): +class SelectUserArticleForm(FormRevMixin, Form): """ Form used to select an article during the creation of an invoice for a member. @@ -127,6 +102,11 @@ class SelectUserArticleForm( required=True ) + def __init__(self, *args, **kwargs): + user = kwargs.pop('user') + super(SelectUserArticleForm, self).__init__(*args, **kwargs) + self.fields['article'].queryset = Article.find_allowed_articles(user) + class SelectClubArticleForm(Form): """ @@ -146,6 +126,10 @@ class SelectClubArticleForm(Form): required=True ) + def __init__(self, user, *args, **kwargs): + super(SelectClubArticleForm, self).__init__(*args, **kwargs) + self.fields['article'].queryset = Article.find_allowed_articles(user) + # TODO : change Facture to Invoice class NewFactureFormPdf(Form): @@ -167,26 +151,6 @@ class NewFactureFormPdf(Form): ) -# TODO : change Facture to Invoice -class EditFactureForm(FieldPermissionFormMixin, NewFactureForm): - """ - Form used to edit an invoice and its fields : payment method, bank, - user associated, ... - """ - class Meta(NewFactureForm.Meta): - # TODO : change Facture to Invoice - model = Facture - fields = '__all__' - - def __init__(self, *args, **kwargs): - # TODO : change Facture to Invoice - super(EditFactureForm, self).__init__(*args, **kwargs) - self.fields['user'].label = _("Member") - self.fields['user'].empty_label = \ - _("Select the proprietary member") - self.fields['valid'].label = _("Validated invoice") - - class ArticleForm(FormRevMixin, ModelForm): """ Form used to create an article. @@ -231,17 +195,12 @@ class PaiementForm(FormRevMixin, ModelForm): class Meta: model = Paiement # TODO : change moyen to method and type_paiement to payment_type - fields = ['moyen', 'type_paiement'] + fields = ['moyen', 'available_for_everyone'] def __init__(self, *args, **kwargs): prefix = kwargs.pop('prefix', self.Meta.model.__name__) super(PaiementForm, self).__init__(*args, prefix=prefix, **kwargs) self.fields['moyen'].label = _("Payment method name") - self.fields['type_paiement'].label = _("Payment type") - self.fields['type_paiement'].help_text = \ - _("The payement type is used for specific behaviour.\ - The \"cheque\" type means a cheque number and a bank name\ - may be added when using this payment method.") # TODO : change paiement to payment @@ -304,56 +263,6 @@ class DelBanqueForm(FormRevMixin, Form): self.fields['banques'].queryset = Banque.objects.all() -# TODO : change facture to Invoice -class NewFactureSoldeForm(NewFactureForm): - """ - Form used to create an invoice - """ - def __init__(self, *args, **kwargs): - prefix = kwargs.pop('prefix', self.Meta.model.__name__) - super(NewFactureSoldeForm, self).__init__( - *args, - prefix=prefix, - **kwargs - ) - self.fields['cheque'].required = False - self.fields['banque'].required = False - self.fields['cheque'].label = _('Cheque number') - self.fields['banque'].empty_label = _("Not specified") - self.fields['paiement'].empty_label = \ - _("Select a payment method") - # TODO : change paiement to payment - paiement_list = Paiement.objects.filter(type_paiement=1) - if paiement_list: - self.fields['paiement'].widget\ - .attrs['data-cheque'] = paiement_list.first().id - - class Meta: - # TODO : change facture to invoice - model = Facture - # TODO : change paiement to payment and baque to bank - fields = ['paiement', 'banque'] - - def clean(self): - cleaned_data = super(NewFactureSoldeForm, self).clean() - # TODO : change paiement to payment - paiement = cleaned_data.get("paiement") - cheque = cleaned_data.get("cheque") - # TODO : change banque to bank - banque = cleaned_data.get("banque") - # TODO : change paiement to payment - if not paiement: - raise forms.ValidationError( - _("A payment method must be specified.") - ) - # TODO : change paiement and banque to payment and bank - elif paiement.type_paiement == "check" and not (cheque and banque): - raise forms.ValidationError( - _("A cheque number and a bank must be specified.") - ) - return cleaned_data - - # TODO : Better name and docstring class RechargeForm(FormRevMixin, Form): """ @@ -364,34 +273,31 @@ class RechargeForm(FormRevMixin, Form): min_value=0.01, validators=[] ) + payment = forms.ModelChoiceField( + queryset=Paiement.objects.none(), + label=_l("Payment method") + ) - def __init__(self, *args, **kwargs): - self.user = kwargs.pop('user') + def __init__(self, *args, user=None, **kwargs): + self.user = user super(RechargeForm, self).__init__(*args, **kwargs) + self.fields['payment'].empty_label = \ + _("Select a payment method") + self.fields['payment'].queryset = Paiement.find_allowed_payments(user) - def clean_value(self): + def clean(self): """ - Returns a cleaned vlaue from the received form by validating + Returns a cleaned value from the received form by validating the value is well inside the possible limits """ value = self.cleaned_data['value'] - if value < OptionalUser.get_cached_value('min_online_payment'): - raise forms.ValidationError( - _("Requested amount is too small. Minimum amount possible : \ - %(min_online_amount)s €.") % { - 'min_online_amount': OptionalUser.get_cached_value( - 'min_online_payment' - ) - } - ) - if value + self.user.solde > \ - OptionalUser.get_cached_value('max_solde'): + balance_method = get_object_or_404(balance.PaymentMethod) + if balance_method.maximum_balance is not None and \ + value + self.user.solde > balance_method.maximum_balance: raise forms.ValidationError( _("Requested amount is too high. Your balance can't exceed \ %(max_online_balance)s €.") % { - 'max_online_balance': OptionalUser.get_cached_value( - 'max_solde' - ) + 'max_online_balance': balance_method.maximum_balance } ) - return value + return self.cleaned_data diff --git a/cotisations/migrations/0030_custom_payment.py b/cotisations/migrations/0030_custom_payment.py new file mode 100644 index 00000000..ec6f08d1 --- /dev/null +++ b/cotisations/migrations/0030_custom_payment.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-07-02 18:56 +from __future__ import unicode_literals + +import re2o.aes_field +import cotisations.payment_methods.mixins +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() + try: + payment = Payment.objects.get( + moyen='Rechargement en ligne' + ) + except Payment.DoesNotExist: + return + comnpay = ComnpayPayment() + comnpay.payment_user = options.payment_id + comnpay.payment = payment + comnpay.save() + payment.moyen = "ComnPay" + + payment.save() + + +def add_solde(apps, schema_editor): + OptionalUser = apps.get_model('preferences', 'OptionalUser') + options, _created = OptionalUser.objects.get_or_create() + + Payment = apps.get_model('cotisations', 'Paiement') + BalancePayment = apps.get_model('cotisations', 'BalancePayment') + + try: + solde = Payment.objects.get(moyen="solde") + except Payment.DoesNotExist: + return + balance = BalancePayment() + balance.payment = solde + balance.minimum_balance = options.solde_negatif + balance.maximum_balance = options.max_solde + solde.is_balance = True + balance.save() + solde.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('preferences', '0044_remove_payment_pass'), + ('cotisations', '0029_auto_20180414_2056'), + ] + + operations = [ + migrations.AlterModelOptions( + name='paiement', + options={'permissions': (('view_paiement', "Can see a payement's details"), ('use_every_payment', 'Can use every payement')), 'verbose_name': 'Payment method', 'verbose_name_plural': 'Payment methods'}, + ), + migrations.AlterModelOptions( + name='article', + options={'permissions': (('view_article', "Can see an article's details"), ('buy_every_article', 'Can buy every_article')), 'verbose_name': 'Article', 'verbose_name_plural': 'Articles'}, + ), + migrations.AddField( + model_name='paiement', + name='available_for_everyone', + field=models.BooleanField(default=False, verbose_name='Is available for every user'), + ), + migrations.AddField( + model_name='paiement', + name='is_balance', + field=models.BooleanField(default=False, editable=False, help_text='There should be only one balance payment method.', verbose_name='Is user balance', validators=[cotisations.models.check_no_balance]), + ), + migrations.AddField( + model_name='article', + name='available_for_everyone', + field=models.BooleanField(default=False, verbose_name='Is available for every user'), + ), + migrations.CreateModel( + name='ChequePayment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('payment', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='payment_method', to='cotisations.Paiement')), + ], + bases=(cotisations.payment_methods.mixins.PaymentMethodMixin, models.Model), + options={'verbose_name': 'Cheque'}, + ), + migrations.CreateModel( + name='ComnpayPayment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('payment_credential', models.CharField(blank=True, default='', max_length=255, verbose_name='ComNpay VAD Number')), + ('payment_pass', re2o.aes_field.AESEncryptedField(blank=True, max_length=255, null=True, verbose_name='ComNpay Secret Key')), + ('payment', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='payment_method', to='cotisations.Paiement')), + ('minimum_payment', models.DecimalField(decimal_places=2, default=1, help_text='The minimal amount of money you have to use when paying with ComNpay', max_digits=5, verbose_name='Minimum payment')), + ], + bases=(cotisations.payment_methods.mixins.PaymentMethodMixin, models.Model), + options={'verbose_name': 'ComNpay'}, + ), + migrations.CreateModel( + name='BalancePayment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('minimum_balance', models.DecimalField(decimal_places=2, default=0, help_text='The minimal amount of money allowed for the balance at the end of a payment. You can specify negative amount.', max_digits=5, verbose_name='Minimum balance')), + ('payment', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='payment_method', to='cotisations.Paiement')), + ('maximum_balance', models.DecimalField(decimal_places=2, default=50, help_text='The maximal amount of money allowed for the balance.', max_digits=5, verbose_name='Maximum balance', null=True, blank=True)), + ('credit_balance_allowed', models.BooleanField(default=False, verbose_name='Allow user to credit their balance')), + ], + bases=(cotisations.payment_methods.mixins.PaymentMethodMixin, models.Model), + options={'verbose_name': 'User Balance'}, + ), + migrations.RunPython(add_comnpay), + migrations.RunPython(add_cheque), + migrations.RunPython(add_solde), + migrations.RemoveField( + model_name='paiement', + name='type_paiement', + ), + + ] diff --git a/cotisations/models.py b/cotisations/models.py index c4c6d4af..52d71b58 100644 --- a/cotisations/models.py +++ b/cotisations/models.py @@ -6,6 +6,7 @@ # Copyright © 2017 Gabriel Détraz # Copyright © 2017 Goulven Kermarec # Copyright © 2017 Augustin Lemesle +# 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 @@ -42,11 +43,17 @@ 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 from re2o.mixins import AclMixin, RevMixin +from cotisations.utils import find_payment_method +from cotisations.validators import check_no_balance + # TODO : change facture to invoice class Facture(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model): @@ -131,7 +138,7 @@ class Facture(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model): """ price = Vente.objects.filter( facture=self - ).aggregate(models.Sum('prix'))['prix__sum'] + ).aggregate(models.Sum('prix'))['prix__sum'] return price # TODO : change prix to price @@ -143,12 +150,12 @@ class Facture(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model): # TODO : change Vente to somethingelse return Vente.objects.filter( facture=self - ).aggregate( - total=models.Sum( - models.F('prix')*models.F('number'), - output_field=models.FloatField() - ) - )['total'] + ).aggregate( + total=models.Sum( + models.F('prix')*models.F('number'), + output_field=models.FloatField() + ) + )['total'] or 0 def name(self): """ @@ -157,7 +164,7 @@ class Facture(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model): """ name = ' - '.join(Vente.objects.filter( facture=self - ).values_list('name', flat=True)) + ).values_list('name', flat=True)) return name def can_edit(self, user_request, *args, **kwargs): @@ -213,6 +220,22 @@ class Facture(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model): _("You don't have the right to edit an invoice.") ) + @staticmethod + def can_create(user_request, *_args, **_kwargs): + """Check if a user can create an invoice. + + :param user_request: The user who wants to create an invoice. + :return: a message and a boolean which is True if the user can create + an invoice or if the `options.allow_self_subscription` is set. + """ + if user_request.has_perm('cotisations.add_facture'): + return True, None + if len(Paiement.find_allowed_payments(user_request)) <= 0: + return False, _("There are no payment types which you can use.") + if len(Article.find_allowed_articles(user_request)) <= 0: + return False, _("There are no article that you can buy.") + return True, None + def __init__(self, *args, **kwargs): super(Facture, self).__init__(*args, **kwargs) self.field_permissions = { @@ -341,12 +364,12 @@ class Vente(RevMixin, AclMixin, models.Model): facture__in=Facture.objects.filter( user=self.facture.user ).exclude(valid=False)) - ).filter( - Q(type_cotisation='All') | - Q(type_cotisation=self.type_cotisation) - ).filter( - date_start__lt=date_start - ).aggregate(Max('date_end'))['date_end__max'] + ).filter( + Q(type_cotisation='All') | + Q(type_cotisation=self.type_cotisation) + ).filter( + date_start__lt=date_start + ).aggregate(Max('date_end'))['date_end__max'] elif self.type_cotisation == "Adhesion": end_cotisation = self.facture.user.end_adhesion() else: @@ -357,7 +380,7 @@ class Vente(RevMixin, AclMixin, models.Model): cotisation.date_start = date_max cotisation.date_end = cotisation.date_start + relativedelta( months=self.duration*self.number - ) + ) return def save(self, *args, **kwargs): @@ -380,7 +403,7 @@ class Vente(RevMixin, AclMixin, models.Model): elif (not user_request.has_perm('cotisations.change_all_facture') and not self.facture.user.can_edit( user_request, *args, **kwargs - )[0]): + )[0]): return False, _("You don't have the right to edit this user's " "purchases.") elif (not user_request.has_perm('cotisations.change_all_vente') and @@ -501,12 +524,17 @@ class Article(RevMixin, AclMixin, models.Model): max_length=255, verbose_name=_l("Type of cotisation") ) + available_for_everyone = models.BooleanField( + default=False, + verbose_name=_l("Is available for every user") + ) unique_together = ('name', 'type_user') class Meta: permissions = ( ('view_article', _l("Can see an article's details")), + ('buy_every_article', _l("Can buy every_article")) ) verbose_name = "Article" verbose_name_plural = "Articles" @@ -524,6 +552,35 @@ class Article(RevMixin, AclMixin, models.Model): def __str__(self): return self.name + def can_buy_article(self, user, *_args, **_kwargs): + """Check if a user can buy this article. + + Args: + self: The article + user: The user requesting buying + + Returns: + A boolean stating if usage is granted and an explanation + message if the boolean is `False`. + """ + return ( + self.available_for_everyone + or user.has_perm('cotisations.buy_every_article') + or user.has_perm('cotisations.add_facture'), + _("You cannot buy this Article.") + ) + + @classmethod + def find_allowed_articles(cls, user): + """Finds every allowed articles for an user. + + Args: + user: The user requesting articles. + """ + if user.has_perm('cotisations.buy_every_article'): + return cls.objects.all() + return cls.objects.filter(available_for_everyone=True) + class Banque(RevMixin, AclMixin, models.Model): """ @@ -557,29 +614,29 @@ class Paiement(RevMixin, AclMixin, models.Model): invoice. It's easier to know this information when doing the accouts. It is represented by: * a name - * a type (used for the type 'cheque' which implies the use of a bank - and an account number in related models) """ - PAYMENT_TYPES = ( - (0, _l("Standard")), - (1, _l("Cheque")), - ) - # TODO : change moyen to method moyen = models.CharField( max_length=255, verbose_name=_l("Method") ) - type_paiement = models.IntegerField( - choices=PAYMENT_TYPES, - default=0, - verbose_name=_l("Payment type") + available_for_everyone = models.BooleanField( + default=False, + verbose_name=_l("Is available for every user") + ) + is_balance = models.BooleanField( + default=False, + editable=False, + verbose_name=_l("Is user balance"), + help_text=_l("There should be only one balance payment method."), + validators=[check_no_balance] ) class Meta: permissions = ( ('view_paiement', _l("Can see a payement's details")), + ('use_every_payment', _l("Can use every payement")), ) verbose_name = _l("Payment method") verbose_name_plural = _l("Payment methods") @@ -593,16 +650,79 @@ class Paiement(RevMixin, AclMixin, models.Model): """ self.moyen = self.moyen.title() - def save(self, *args, **kwargs): + def end_payment(self, invoice, request, use_payment_method=True): """ - Override of the herited save function to be sure only one payment - method of type 'cheque' exists. + The general way of ending a payment. + + Args: + invoice: The invoice being created. + request: Request sent by the user. + use_payment_method: If this flag is set to True and`self` has + an attribute `payment_method`, returns the result of + `self.payment_method.end_payment(invoice, request)` + + Returns: + An `HttpResponse`-like object. """ - if Paiement.objects.filter(type_paiement=1).count() > 1: - raise ValidationError( - _("You cannot have multiple payment method of type cheque") + payment_method = find_payment_method(self) + if payment_method is not None and use_payment_method: + return 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': invoice.user.pseudo, + 'end_date': invoice.user.end_adhesion() + } ) - super(Paiement, self).save(*args, **kwargs) + # Else, only tell the invoice was created + else: + messages.success( + request, + _("The invoice has been created.") + ) + return redirect(reverse( + 'users:profil', + kwargs={'userid': invoice.user.pk} + )) + + def can_use_payment(self, user, *_args, **_kwargs): + """Check if a user can use this payment. + + Args: + self: The payment + user: The user requesting usage + Returns: + A boolean stating if usage is granted and an explanation + message if the boolean is `False`. + """ + return ( + self.available_for_everyone + or user.has_perm('cotisations.use_every_payment') + or user.has_perm('cotisations.add_facture'), + _("You cannot use this Payment.") + ) + + @classmethod + def find_allowed_payments(cls, user): + """Finds every allowed payments for an user. + + Args: + user: The user requesting payment methods. + """ + if user.has_perm('cotisations.use_every_payment'): + return cls.objects.all() + return cls.objects.filter(available_for_everyone=True) + + def get_payment_method_name(self): + p = find_payment_method(self) + if p is not None: + return p._meta.verbose_name + return _("No custom payment method") 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..b78a86fd --- /dev/null +++ b/cotisations/payment_methods/__init__.py @@ -0,0 +1,136 @@ +# -*- mode: python; 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 © 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. +""" +# Custom Payment methods + +When creating an invoice with a classic payment method, the creation view calls +the `end_payment` method of the `Payment` object of the invoice. This method +checks for a payment method associated to the `Payment` and if nothing is +found, adds a message for payment confirmation and redirects the user towards +their profil page. This is fine for most of the payment method, but you might +want to define custom payment methods. As an example for negociating with an +other server for online payment or updating some fields in your models. + +# Defining a custom payment method +To define a custom payment method, you can add a Python module to +`cotisations/payment_methods/`. This module should be organized like +a Django application. +As an example, if you want to add the payment method `foo`. + +## Basic + +The first thing to do is to create a `foo` Python module with a `models.py`. + +``` +payment_methods +├── foo +│ ├── __init__.py +│ └── models.py +├── forms.py +├── __init__.py +├── mixins.py +└── urls.py +``` + +Then, in `models.py` you could add a model like this : +```python +from django.db import models + +from cotisations.models import Paiement +from cotisations.payment_methods.mixins import PaymentMethodMixin + + +# The `PaymentMethodMixin` defines the default `end_payment` +class FooPayment(PaymentMethodMixin, models.Model): + + # This field is required, it is used by `Paiement` in order to + # determine if a payment method is associated to it. + payment = models.OneToOneField( + Paiement, + on_delete=models.CASCADE, + related_name='payment_method', + editable=False + ) +``` + +And in `__init__.py` : +```python +from . import models +NAME = "FOO" # Name displayed when you crate a payment type +PaymentMethod = models.FooPayment # You must define this alias +``` + +Then you just have to register your payment method in +`payment_methods/__init__.py` in the `PAYMENT_METHODS` list : + +``` +from . import ... # Some existing imports +from . import foo + +PAYMENT_METHODS = [ + # Some already registered payment methods... + foo +] +``` + +And... that's it, you can use your new payment method after running +`makemigrations` and `migrate`. + +But this payment method is not really usefull, since it does noting ! + +## A payment method which does something + +You have to redefine the `end_payment` method. Here is its prototype : + +```python +def end_payment(self, invoice, request): + pass +``` + +With `invoice` the invoice being created and `request` the request which +created it. This method has to return an HttpResponse-like object. + +## Additional views + +You can add specific urls for your payment method like in any django app. To +register these urls, modify `payment_methods/urls.py`. + +## Alter the `Paiement` object after creation + +You can do that by adding a `alter_payment(self, payment)` +method to your model. + +## Validate the creation field + +You may want to perform some additionals verifications on the form +creating the payment. You can do that by adding a `valid_form(self, form)` +method to your model, where `form` is an instance of +`cotisations.payment_methods.forms.PaymentMethodForm`. +""" + + +from . import comnpay, cheque, balance, urls + +PAYMENT_METHODS = [ + comnpay, + cheque, + balance, +] diff --git a/cotisations/payment_methods/balance/__init__.py b/cotisations/payment_methods/balance/__init__.py new file mode 100644 index 00000000..cacd73f7 --- /dev/null +++ b/cotisations/payment_methods/balance/__init__.py @@ -0,0 +1,27 @@ +# -*- mode: python; 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 © 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. +""" +This module contains a method to pay online using user balance. +""" +from . import models +NAME = "BALANCE" + +PaymentMethod = models.BalancePayment diff --git a/cotisations/payment_methods/balance/models.py b/cotisations/payment_methods/balance/models.py new file mode 100644 index 00000000..4e488405 --- /dev/null +++ b/cotisations/payment_methods/balance/models.py @@ -0,0 +1,120 @@ +# -*- mode: python; 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 © 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. +from django.db import models +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_lazy as _l +from django.contrib import messages + + +from cotisations.models import Paiement +from cotisations.payment_methods.mixins import PaymentMethodMixin + + +class BalancePayment(PaymentMethodMixin, models.Model): + """ + The model allowing you to pay with a cheque. + """ + + class Meta: + verbose_name = _l("User Balance") + + payment = models.OneToOneField( + Paiement, + on_delete=models.CASCADE, + related_name='payment_method', + editable=False + ) + minimum_balance = models.DecimalField( + verbose_name=_l("Minimum balance"), + help_text=_l("The minimal amount of money allowed for the balance" + " at the end of a payment. You can specify negative " + "amount." + ), + max_digits=5, + decimal_places=2, + default=0, + ) + maximum_balance = models.DecimalField( + verbose_name=_l("Maximum balance"), + help_text=_l("The maximal amount of money allowed for the balance."), + max_digits=5, + decimal_places=2, + default=50, + blank=True, + null=True, + ) + credit_balance_allowed = models.BooleanField( + verbose_name=_l("Allow user to credit their balance"), + default=False, + ) + + def end_payment(self, invoice, request): + """Changes the user's balance to pay the invoice. If it is not + possible, shows an error and invalidates the invoice. + """ + user = invoice.user + total_price = invoice.prix_total() + if float(user.solde) - float(total_price) < self.minimum_balance: + invoice.valid = False + invoice.save() + messages.error( + request, + _("Your balance is too low for this operation.") + ) + return redirect(reverse( + 'users:profil', + kwargs={'userid': user.id} + )) + return invoice.paiement.end_payment( + invoice, + request, + use_payment_method=False + ) + + def valid_form(self, form): + """Checks that there is not already a balance payment method.""" + p = Paiement.objects.filter(is_balance=True) + if len(p) > 0: + form.add_error( + 'payment_method', + _("There is already a payment type for user balance") + ) + + def alter_payment(self, payment): + """Register the payment as a balance payment.""" + self.payment.is_balance = True + + def check_price(self, price, user, *args, **kwargs): + """Checks that the price meets the requirement to be paid with user + balance. + """ + return ( + float(user.solde) - float(price) >= self.minimum_balance, + _("Your balance is too low for this operation.") + ) + + def can_credit_balance(self, user_request): + return ( + len(Paiement.find_allowed_payments(user_request) + .exclude(is_balance=True)) > 0 + ) and self.credit_balance_allowed diff --git a/cotisations/payment_methods/cheque/__init__.py b/cotisations/payment_methods/cheque/__init__.py new file mode 100644 index 00000000..9eb17b09 --- /dev/null +++ b/cotisations/payment_methods/cheque/__init__.py @@ -0,0 +1,27 @@ +# -*- mode: python; 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 © 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. +""" +This module contains a method to pay online using cheque. +""" +from . import models, urls, views +NAME = "CHEQUE" + +PaymentMethod = models.ChequePayment diff --git a/cotisations/payment_methods/cheque/forms.py b/cotisations/payment_methods/cheque/forms.py new file mode 100644 index 00000000..37942687 --- /dev/null +++ b/cotisations/payment_methods/cheque/forms.py @@ -0,0 +1,31 @@ +# -*- mode: python; 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 © 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. +from django import forms + +from re2o.mixins import FormRevMixin +from cotisations.models import Facture as Invoice + + +class InvoiceForm(FormRevMixin, forms.ModelForm): + """A simple form to get the bank a the cheque number.""" + class Meta: + model = Invoice + fields = ['banque', 'cheque'] diff --git a/cotisations/payment_methods/cheque/models.py b/cotisations/payment_methods/cheque/models.py new file mode 100644 index 00000000..c2680e7a --- /dev/null +++ b/cotisations/payment_methods/cheque/models.py @@ -0,0 +1,54 @@ +# -*- mode: python; 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 © 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. +from django.db import models +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _l + +from cotisations.models import Paiement +from cotisations.payment_methods.mixins import PaymentMethodMixin + + +class ChequePayment(PaymentMethodMixin, models.Model): + """ + The model allowing you to pay with a cheque. + """ + + class Meta: + verbose_name = _l("Cheque") + + payment = models.OneToOneField( + Paiement, + on_delete=models.CASCADE, + related_name='payment_method', + editable=False + ) + + def end_payment(self, invoice, request): + """Invalidates the invoice then redirect the user towards a view asking + for informations to add to the invoice before validating it. + """ + 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..54fe6a50 --- /dev/null +++ b/cotisations/payment_methods/cheque/urls.py @@ -0,0 +1,30 @@ +# -*- mode: python; 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 © 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. +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..4d164a79 --- /dev/null +++ b/cotisations/payment_methods/cheque/views.py @@ -0,0 +1,69 @@ +# -*- mode: python; 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 © 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. +"""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 cotisations.utils import find_payment_method + +from .models import ChequePayment +from .forms import InvoiceForm + + +@login_required +def cheque(request, invoice_pk): + """This view validate an invoice with the data from a cheque.""" + invoice = get_object_or_404(Invoice, pk=invoice_pk) + payment_method = find_payment_method(invoice.paiement) + 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 = InvoiceForm(request.POST or None, instance=invoice) + if form.is_valid(): + form.instance.valid = True + form.save() + return form.instance.paiement.end_payment( + form.instance, + request, + use_payment_method=False + ) + return render( + request, + 'cotisations/payment.html', + { + 'form': form, + 'amount': invoice.prix_total() + } + ) diff --git a/cotisations/payment_methods/comnpay/__init__.py b/cotisations/payment_methods/comnpay/__init__.py new file mode 100644 index 00000000..7c80311d --- /dev/null +++ b/cotisations/payment_methods/comnpay/__init__.py @@ -0,0 +1,26 @@ +# -*- mode: python; 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 © 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. +""" +This module contains a method to pay online using comnpay. +""" +from . import models, urls, views +NAME = "COMNPAY" +PaymentMethod = models.ComnpayPayment 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..ff6fed0d --- /dev/null +++ b/cotisations/payment_methods/comnpay/models.py @@ -0,0 +1,108 @@ +# -*- mode: python; 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 © 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. +from django.db import models +from django.shortcuts import render +from django.urls import reverse +from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_lazy as _l + +from cotisations.models import Paiement +from cotisations.payment_methods.mixins import PaymentMethodMixin + +from re2o.aes_field import AESEncryptedField +from .comnpay import Transaction + + +class ComnpayPayment(PaymentMethodMixin, models.Model): + """ + The model allowing you to pay with COMNPAY. + """ + + class Meta: + verbose_name = "ComNpay" + + payment = models.OneToOneField( + Paiement, + on_delete=models.CASCADE, + related_name='payment_method', + editable=False + ) + payment_credential = models.CharField( + max_length=255, + default='', + blank=True, + verbose_name=_l("ComNpay VAD Number"), + ) + payment_pass = AESEncryptedField( + max_length=255, + null=True, + blank=True, + verbose_name=_l("ComNpay Secret Key"), + ) + minimum_payment = models.DecimalField( + verbose_name=_l("Minimum payment"), + help_text=_l("The minimal amount of money you have to use when paying" + " with ComNpay"), + max_digits=5, + decimal_places=2, + default=1, + ) + + def end_payment(self, invoice, request): + """ + Build a request to start the negociation with Comnpay by using + a facture id, the price and the secret transaction data stored in + the preferences. + """ + invoice.valid = False + invoice.save() + host = request.get_host() + p = Transaction( + str(self.payment_credential), + str(self.payment_pass), + 'https://' + host + reverse( + 'cotisations:comnpay:accept_payment', + kwargs={'factureid': invoice.id} + ), + 'https://' + host + reverse('cotisations:comnpay:refuse_payment'), + 'https://' + host + reverse('cotisations:comnpay:ipn'), + "", + "D" + ) + r = { + 'action': 'https://secure.homologation.comnpay.com', + 'method': 'POST', + 'content': p.buildSecretHTML( + _("Pay invoice no : ")+str(invoice.id), + invoice.prix_total(), + idTransaction=str(invoice.id) + ), + 'amount': invoice.prix_total(), + } + return render(request, 'cotisations/payment.html', r) + + def check_price(self, price, *args, **kwargs): + """Checks that the price meets the requirement to be paid with ComNpay. + """ + return ((price >= self.minimum_payment), + _('In order to pay your invoice with ComNpay' + ', the price must be grater than {} €') + .format(self.minimum_payment)) diff --git a/cotisations/payment_methods/comnpay/urls.py b/cotisations/payment_methods/comnpay/urls.py new file mode 100644 index 00000000..241d53dd --- /dev/null +++ b/cotisations/payment_methods/comnpay/urls.py @@ -0,0 +1,40 @@ +# -*- mode: python; 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 © 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. +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 50% rename from cotisations/payment.py rename to cotisations/payment_methods/comnpay/views.py index b03e1c55..89966b48 100644 --- a/cotisations/payment.py +++ b/cotisations/payment_methods/comnpay/views.py @@ -1,6 +1,26 @@ +# -*- mode: python; 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 © 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. """Payment -Here are defined some views dedicated to online payement. +Here are the views needed by comnpay """ from collections import OrderedDict @@ -14,24 +34,38 @@ from django.utils.datastructures import MultiValueDictKeyError 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 +from .models import ComnpayPayment @csrf_exempt @login_required def accept_payment(request, factureid): """ - The view called when an online payment has been accepted. + The view where the user is redirected when a comnpay payment has been + accepted. """ - facture = get_object_or_404(Facture, id=factureid) - messages.success( - request, - _("The payment of %(amount)s € has been accepted.") % { - 'amount': facture.prix() - } - ) + invoice = get_object_or_404(Facture, id=factureid) + if invoice.valid: + messages.success( + request, + _("The payment of %(amount)s € has been accepted.") % { + 'amount': invoice.prix_total() + } + ) + # In case a cotisation was bought, inform the user, the + # cotisation time has been extended too + if any(purchase.type_cotisation + for purchase 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() + } + ) return redirect(reverse( 'users:profil', kwargs={'userid': request.user.id} @@ -42,7 +76,8 @@ def accept_payment(request, factureid): @login_required def refuse_payment(request): """ - The view called when an online payment has been refused. + The view where the user is redirected when a comnpay payment has been + refused. """ messages.error( request, @@ -59,37 +94,38 @@ def ipn(request): """ The view called by Comnpay server to validate the transaction. 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 + 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]) except MultiValueDictKeyError: return HttpResponseBadRequest("HTTP/1.1 400 Bad Request") - if not p.validSec(data, AssoOption.get_cached_value('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'] - - # Checking that the payment is actually for us. - if not idTpe == AssoOption.get_cached_value('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) + payment_method = get_object_or_404( + ComnpayPayment, payment=facture.paiement) - # Checking that the payment is valid + if not p.validSec(data, payment_method.payment_pass): + return HttpResponseBadRequest("HTTP/1.1 400 Bad Request") + + result = True if (request.POST['result'] == 'OK') else False + idTpe = request.POST['idTpe'] + + # Checking that the payment is actually for us. + if not idTpe == payment_method.payment_credential: + return HttpResponseBadRequest("HTTP/1.1 400 Bad Request") + + # Checking that the payment is valid if not result: # Payment failed: Cancelling the invoice operation - facture.delete() # And send the response to Comnpay indicating we have well # received the failure information. return HttpResponse("HTTP/1.1 200 OK") @@ -100,42 +136,3 @@ def ipn(request): # Everything worked we send a reponse to Comnpay indicating that # it's ok for them to proceed return HttpResponse("HTTP/1.0 200 OK") - - -def comnpay(facture, request): - """ - Build a request to start the negociation with Comnpay by using - a facture id, the price and the secret transaction data stored in - the preferences. - """ - host = request.get_host() - p = ComnpayPayment( - str(AssoOption.get_cached_value('payment_id')), - str(AssoOption.get_cached_value('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 - - -# The payment systems supported by re2o -PAYMENT_SYSTEM = { - 'COMNPAY': comnpay, - 'NONE': None -} diff --git a/cotisations/payment_methods/forms.py b/cotisations/payment_methods/forms.py new file mode 100644 index 00000000..d4d55a74 --- /dev/null +++ b/cotisations/payment_methods/forms.py @@ -0,0 +1,115 @@ +# -*- mode: python; 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 © 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. +from django import forms +from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_lazy as _l + +from . import PAYMENT_METHODS +from cotisations.utils import find_payment_method + +def payment_method_factory(payment, *args, creation=True, **kwargs): + """This function finds the right payment method form for a given payment. + + If the payment has a payment method, returns a ModelForm of it. Else if + it is the creation of the payment, a `PaymentMethodForm`. + Else `None`. + + Args: + payment: The payment + *args: arguments passed to the form + creation: Should be True if you are creating the payment + **kwargs: passed to the form + + Returns: + A form or None + """ + payment_method = kwargs.pop('instance', find_payment_method(payment)) + if payment_method is not None: + return forms.modelform_factory(type(payment_method), fields='__all__')( + *args, + instance=payment_method, + **kwargs + ) + elif creation: + return PaymentMethodForm(*args, **kwargs) + + +class PaymentMethodForm(forms.Form): + """A special form which allows you to add a payment method to a `Payment` + object. + """ + + payment_method = forms.ChoiceField( + label=_l("Special payment method"), + help_text=_l("Warning : You will not be able to change the payment " + "method later. But you will be allowed to edit the other " + "options." + ), + required=False + ) + + def __init__(self, *args, **kwargs): + super(PaymentMethodForm, self).__init__(*args, **kwargs) + prefix = kwargs.get('prefix', None) + self.fields['payment_method'].choices = [(i,p.NAME) for (i,p) in enumerate(PAYMENT_METHODS)] + self.fields['payment_method'].choices.insert(0, ('', _l('no'))) + self.fields['payment_method'].widget.attrs = { + 'id': 'paymentMethodSelect' + } + self.templates = [ + forms.modelform_factory(p.PaymentMethod, fields='__all__')(prefix=prefix) + for p in PAYMENT_METHODS + ] + + def clean(self): + """A classic `clean` method, except that it replaces + `self.payment_method` by the payment method object if one has been + found. Tries to call `payment_method.valid_form` if it exists. + """ + super(PaymentMethodForm, self).clean() + choice = self.cleaned_data['payment_method'] + if choice=='': + return + choice = int(choice) + model = PAYMENT_METHODS[choice].PaymentMethod + form = forms.modelform_factory(model, fields='__all__')(self.data, prefix=self.prefix) + self.payment_method = form.save(commit=False) + if hasattr(self.payment_method, 'valid_form'): + self.payment_method.valid_form(self) + return self.cleaned_data + + + + def save(self, payment, *args, **kwargs): + """Saves the payment method. + + Tries to call `payment_method.alter_payment` if it exists. + """ + commit = kwargs.pop('commit', True) + if not hasattr(self, 'payment_method'): + return None + self.payment_method.payment = payment + if hasattr(self.payment_method, 'alter_payment'): + self.payment_method.alter_payment(payment) + if commit: + payment.save() + self.payment_method.save() + return self.payment_method diff --git a/cotisations/payment_methods/mixins.py b/cotisations/payment_methods/mixins.py new file mode 100644 index 00000000..12503e05 --- /dev/null +++ b/cotisations/payment_methods/mixins.py @@ -0,0 +1,33 @@ +# -*- mode: python; 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 © 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. + + +class PaymentMethodMixin: + """A simple mixin to avoid redefining end_payment if you don't need to""" + + def end_payment(self, invoice, request): + """Redefine this method in order to get a different ending to the + payment session if you whish. + + Must return a HttpResponse-like object. + """ + return self.payment.end_payment( + invoice, request, use_payment_method=False) diff --git a/cotisations/payment_methods/urls.py b/cotisations/payment_methods/urls.py new file mode 100644 index 00000000..20e50255 --- /dev/null +++ b/cotisations/payment_methods/urls.py @@ -0,0 +1,27 @@ +# -*- mode: python; 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 © 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. +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_utils/__init__.py b/cotisations/payment_utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cotisations/templates/cotisations/aff_article.html b/cotisations/templates/cotisations/aff_article.html index eb4ec0a7..ad044f55 100644 --- a/cotisations/templates/cotisations/aff_article.html +++ b/cotisations/templates/cotisations/aff_article.html @@ -33,6 +33,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% trans "Cotisation type" %} {% trans "Duration (month)" %} {% trans "Concerned users" %} + {% trans "Available for everyone" %} @@ -42,7 +43,8 @@ with this program; if not, write to the Free Software Foundation, Inc., {{ article.prix }} {{ article.type_cotisation }} {{ article.duration }} - {{ article.type_user }} + {{ article.type_user }} + {{ article.available_for_everyone }} {% can_edit article %} diff --git a/cotisations/templates/cotisations/aff_paiement.html b/cotisations/templates/cotisations/aff_paiement.html index 63a5cf35..e42d512f 100644 --- a/cotisations/templates/cotisations/aff_paiement.html +++ b/cotisations/templates/cotisations/aff_paiement.html @@ -28,13 +28,19 @@ with this program; if not, write to the Free Software Foundation, Inc., - + + + {% for paiement in paiement_list %} + + - - - + + + - +
{% trans "Payment method" %}{% trans "Payment type" %}{% trans "Is available for everyone" %}{% trans "Custom payment method" %}
{{ paiement.moyen }}{{ paiement.available_for_everyone }} + {{paiement.get_payment_method_name}} + {% can_edit paiement %} diff --git a/cotisations/templates/cotisations/facture.html b/cotisations/templates/cotisations/facture.html index dc8648ac..4ec05ec1 100644 --- a/cotisations/templates/cotisations/facture.html +++ b/cotisations/templates/cotisations/facture.html @@ -30,10 +30,27 @@ with this program; if not, write to the Free Software Foundation, Inc., {% block title %}{% trans "Invoices creation and edition" %}{% endblock %} {% block content %} -{% bootstrap_form_errors factureform %} +{% if title %} +

{{title}}

+{% else %} +

{% trans "New invoice" %}

+{% endif %} +{% if max_balance %} +

{% trans "Maximum allowed balance : "%}{{max_balance}} €

+{% endif %} +{% if balance is not None %} +

+ {% trans "Current balance :" %} {{ balance }} € +

+{% endif %}
{% csrf_token %} + {% bootstrap_form factureform %} + {% if payment_method %} + {% bootstrap_form payment_method %} +
+ {% endif %} {% if articlesformset %}

{% trans "Invoice's articles" %}

@@ -56,17 +73,17 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endblocktrans %}

{% endif %} - {% bootstrap_form factureform %} {% bootstrap_button action_name button_type='submit' icon='star' %} -{% if articlesformset %} +{% if articlesformset or payment_method%} {% endif %} diff --git a/cotisations/templates/cotisations/new_facture.html b/cotisations/templates/cotisations/new_facture.html deleted file mode 100644 index 4dab92d3..00000000 --- a/cotisations/templates/cotisations/new_facture.html +++ /dev/null @@ -1,164 +0,0 @@ -{% 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%} -{% load i18n %} - -{% block title %}{% trans "Invoices creation and edition" %}{% endblock %} - -{% block content %} -{% bootstrap_form_errors factureform %} - -
- {% csrf_token %} -

{% trans "New invoice" %}

-

- {% blocktrans %} - User's balance : {{ user.solde }} € - {% endblocktrans %} -

- {% bootstrap_form factureform %} - {{ venteform.management_form }} - -

{% trans "Invoice's articles" %}

-
- {% for form in venteform.forms %} -
- {% trans "Article" %} :   - {% bootstrap_form form label_class='sr-only' %} -   - -
- {% endfor %} -
- -

- {% blocktrans %} - Total price : 0,00 € - {% endblocktrans %} -

- {% trans "Create" as tr_create %} - {% bootstrap_button tr_create button_type='submit' icon='star' %} -
- - - -{% endblock %} diff --git a/cotisations/templates/cotisations/new_facture_solde.html b/cotisations/templates/cotisations/new_facture_solde.html deleted file mode 100644 index dac68c54..00000000 --- a/cotisations/templates/cotisations/new_facture_solde.html +++ /dev/null @@ -1,158 +0,0 @@ -{% 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%} -{% load i18n %} - -{% block title %}{% trans "Invoices creation and edition" %}{% endblock %} - -{% block content %} -{% bootstrap_form_errors venteform.management_form %} - -
- {% csrf_token %} -

{% trans "New invoice" %}

- {{ venteform.management_form }} - -

{% trans "Invoice's articles" %}

-
- {% for form in venteform.forms %} -
- {% trans "Article" %} :   - {% bootstrap_form form label_class='sr-only' %} -   - -
- {% endfor %} -
- -

- {% blocktrans %} - Total price : 0,00 € - {% endblocktrans %} -

- {% trans "Confirm" as tr_confirm %} - {% bootstrap_button tr_confirm button_type='submit' icon='star' %} -
- - - -{% endblock %} - diff --git a/cotisations/templates/cotisations/payment.html b/cotisations/templates/cotisations/payment.html index a0a1abae..e1c8b0d0 100644 --- a/cotisations/templates/cotisations/payment.html +++ b/cotisations/templates/cotisations/payment.html @@ -32,11 +32,15 @@ with this program; if not, write to the Free Software Foundation, Inc., {% block content %}

{% blocktrans %} - Refill of {{ amount }} € + Pay {{ amount }} € {% endblocktrans %}

-
+ {{ content | safe }} + {% if form %} + {% csrf_token %} + {% bootstrap_form form %} + {% endif %} {% trans "Pay" as tr_pay %} {% bootstrap_button tr_pay button_type='submit' icon='piggy-bank' %}
diff --git a/cotisations/templates/cotisations/recharge.html b/cotisations/templates/cotisations/recharge.html deleted file mode 100644 index 196de2ca..00000000 --- a/cotisations/templates/cotisations/recharge.html +++ /dev/null @@ -1,45 +0,0 @@ -{% 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%} -{% load i18n %} - -{% block title %}{% trans "Balance refill" %}{% endblock %} - -{% block content %} -

{% trans "Balance refill" %}

-

- {% blocktrans %} - Balance : {{ request.user.solde }} € - {% endblocktrans %} -

-
- {% csrf_token %} - {% bootstrap_form rechargeform %} - {% trans "Confirm" as tr_confirm %} - {% bootstrap_button tr_confirm button_type='submit' icon='piggy-bank' %} -
-{% endblock %} diff --git a/cotisations/urls.py b/cotisations/urls.py index 6eafe721..c906c54a 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( @@ -133,30 +133,5 @@ urlpatterns = [ views.control, name='control' ), - url( - r'^new_facture_solde/(?P[0-9]+)$', - views.new_facture_solde, - name='new_facture_solde' - ), - url( - r'^recharge/$', - 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.urls.urlpatterns diff --git a/cotisations/utils.py b/cotisations/utils.py new file mode 100644 index 00000000..f36b376f --- /dev/null +++ b/cotisations/utils.py @@ -0,0 +1,32 @@ +# -*- mode: python; 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 © 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. + + +def find_payment_method(payment): + """Finds the payment method associated to the payment if it exists.""" + from cotisations.payment_methods import PAYMENT_METHODS + for method in PAYMENT_METHODS: + try: + o = method.PaymentMethod.objects.get(payment=payment) + return o + except method.PaymentMethod.DoesNotExist: + pass + return None diff --git a/cotisations/validators.py b/cotisations/validators.py new file mode 100644 index 00000000..fa8ea2cf --- /dev/null +++ b/cotisations/validators.py @@ -0,0 +1,21 @@ +from django.forms import ValidationError +from django.utils.translation import ugettext as _ + + +def check_no_balance(is_balance): + """This functions checks that no Paiement with is_balance=True exists + + Args: + is_balance: True if the model is balance. + + Raises: + ValidationError: if such a Paiement exists. + """ + from .models import Paiement + if not is_balance: + return + p = Paiement.objects.filter(is_balance=True) + if len(p) > 0: + raise ValidationError( + _("There are already payment method(s) for user balance") + ) diff --git a/cotisations/views.py b/cotisations/views.py index 47076c8f..8b9fe79e 100644 --- a/cotisations/views.py +++ b/cotisations/views.py @@ -5,6 +5,7 @@ # Copyright © 2017 Gabriel Détraz # Copyright © 2017 Goulven Kermarec # Copyright © 2017 Augustin Lemesle +# 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 @@ -31,7 +32,7 @@ from __future__ import unicode_literals import os from django.urls import reverse -from django.shortcuts import render, redirect +from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required from django.contrib import messages from django.db.models import ProtectedError @@ -56,11 +57,10 @@ from re2o.acl import ( can_delete_set, can_change, ) -from preferences.models import OptionalUser, AssoOption, GeneralOption +from preferences.models import AssoOption, GeneralOption from .models import Facture, Article, Vente, Paiement, Banque from .forms import ( - NewFactureForm, - EditFactureForm, + FactureForm, ArticleForm, DelArticleForm, PaiementForm, @@ -70,11 +70,11 @@ from .forms import ( NewFactureFormPdf, SelectUserArticleForm, SelectClubArticleForm, - CreditSoldeForm, RechargeForm ) -from . import payment as online_payment from .tex import render_invoice +from .payment_methods.forms import payment_method_factory +from .utils import find_payment_method @login_required @@ -84,29 +84,33 @@ def new_facture(request, user, userid): """ View called to create a new invoice. Currently, Send the list of available articles for the user along with - a formset of a new invoice (based on the `:forms:NewFactureForm()` form. + a formset of a new invoice (based on the `:forms:FactureForm()` form. A bit of JS is used in the template to add articles in a fancier way. If everything is correct, save each one of the articles, save the purchase object associated and finally the newly created invoice. - - TODO : The whole verification process should be moved to the model. This - function should only act as a dumb interface between the model and the - user. """ invoice = Facture(user=user) # The template needs the list of articles (for the JS part) article_list = Article.objects.filter( Q(type_user='All') | Q(type_user=request.user.class_name) ) - # Building the invocie form and the article formset - invoice_form = NewFactureForm(request.POST or None, instance=invoice) + # Building the invoice form and the article formset + invoice_form = FactureForm( + request.POST or None, + instance=invoice, + user=request.user, + creation=True + ) + if request.user.is_class_club: article_formset = formset_factory(SelectClubArticleForm)( - request.POST or None + request.POST or None, + form_kwargs={'user': request.user} ) else: article_formset = formset_factory(SelectUserArticleForm)( - request.POST or None + request.POST or None, + form_kwargs={'user': request.user} ) if invoice_form.is_valid() and article_formset.is_valid(): @@ -114,42 +118,15 @@ def new_facture(request, user, userid): articles = article_formset # Check if at leat one article has been selected if any(art.cleaned_data for art in articles): - user_balance = OptionalUser.get_cached_value('user_solde') - negative_balance = OptionalUser.get_cached_value('solde_negatif') - # If the paiement using balance has been activated, - # checking that the total price won't get the user under - # the authorized minimum (negative_balance) - if user_balance: - # TODO : change Paiement to Payment - if new_invoice_instance.paiement == ( - Paiement.objects.get_or_create(moyen='solde')[0] - ): - total_price = 0 - for art_item in articles: - if art_item.cleaned_data: - total_price += ( - art_item.cleaned_data['article'].prix * - art_item.cleaned_data['quantity'] - ) - if (float(user.solde) - float(total_price) - < negative_balance): - messages.error( - request, - _("Your balance is too low for this operation.") - ) - return redirect(reverse( - 'users:profil', - kwargs={'userid': userid} - )) - # Saving the invoice - new_invoice_instance.save() - # Building a purchase for each article sold + purchases = [] + total_price = 0 for art_item in articles: if art_item.cleaned_data: article = art_item.cleaned_data['article'] quantity = art_item.cleaned_data['quantity'] - new_purchase = Vente.objects.create( + total_price += article.prix*quantity + new_purchase = Vente( facture=new_invoice_instance, name=article.name, prix=article.prix, @@ -157,41 +134,42 @@ def new_facture(request, user, userid): duration=article.duration, number=quantity ) - new_purchase.save() - - # 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 + purchases.append(new_purchase) + p = find_payment_method(new_invoice_instance.paiement) + if hasattr(p, 'check_price'): + price_ok, msg = p.check_price(total_price, user) + invoice_form.add_error(None, msg) else: - messages.success( - request, - _("The invoice has been created.") + price_ok = True + if price_ok: + new_invoice_instance.save() + for p in purchases: + p.facture = new_invoice_instance + p.save() + + return new_invoice_instance.paiement.end_payment( + new_invoice_instance, + request ) - return redirect(reverse( - 'users:profil', - kwargs={'userid': userid} - )) - messages.error( - request, - _("You need to choose at least one article.") - ) + else: + messages.error( + request, + _("You need to choose at least one article.") + ) + p = Paiement.objects.filter(is_balance=True) + if len(p) and p[0].can_use_payment(request.user): + balance = user.solde + else: + balance = None return form( { 'factureform': invoice_form, - 'venteform': article_formset, - 'articlelist': article_list + 'articlesformset': article_formset, + 'articlelist': article_list, + 'balance': balance, + 'action_name': _('Create'), }, - 'cotisations/new_facture.html', request + 'cotisations/facture.html', request ) @@ -212,11 +190,13 @@ def new_facture_pdf(request): invoice_form = NewFactureFormPdf(request.POST or None) if request.user.is_class_club: articles_formset = formset_factory(SelectClubArticleForm)( - request.POST or None + request.POST or None, + form_kwargs={'user': request.user} ) else: articles_formset = formset_factory(SelectUserArticleForm)( - request.POST or None + request.POST or None, + form_kwargs={'user': request.user} ) if invoice_form.is_valid() and articles_formset.is_valid(): # Get the article list and build an list out of it @@ -251,13 +231,13 @@ def new_facture_pdf(request): 'email': AssoOption.get_cached_value('contact'), 'phone': AssoOption.get_cached_value('telephone'), 'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH) - }) + }) return form({ 'factureform': invoice_form, 'action_name': _("Create"), 'articlesformset': articles_formset, 'articles': articles - }, 'cotisations/facture.html', request) + }, 'cotisations/facture.html', request) # TODO : change facture to invoice @@ -300,7 +280,7 @@ def facture_pdf(request, facture, **_kwargs): 'email': AssoOption.get_cached_value('contact'), 'phone': AssoOption.get_cached_value('telephone'), 'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH) - }) + }) # TODO : change facture to invoice @@ -313,7 +293,7 @@ def edit_facture(request, facture, **_kwargs): can be set as desired. This is also the view used to invalidate an invoice. """ - invoice_form = EditFactureForm( + invoice_form = FactureForm( request.POST or None, instance=facture, user=request.user @@ -324,7 +304,7 @@ def edit_facture(request, facture, **_kwargs): fields=('name', 'number'), extra=0, max_num=len(purchases_objects) - ) + ) purchase_form = purchase_form_set( request.POST or None, queryset=purchases_objects @@ -341,7 +321,7 @@ def edit_facture(request, facture, **_kwargs): return form({ 'factureform': invoice_form, 'venteform': purchase_form - }, 'cotisations/edit_facture.html', request) + }, 'cotisations/edit_facture.html', request) # TODO : change facture to invoice @@ -361,40 +341,7 @@ def del_facture(request, facture, **_kwargs): return form({ 'objet': facture, 'objet_name': _("Invoice") - }, 'cotisations/delete.html', request) - - -# TODO : change solde to balance -@login_required -@can_create(Facture) -@can_edit(User) -def credit_solde(request, user, **_kwargs): - """ - View used to edit the balance of a user. - Can be use either to increase or decrease a user's balance. - """ - # TODO : change facture to invoice - invoice = CreditSoldeForm(request.POST or None) - if invoice.is_valid(): - invoice_instance = invoice.save(commit=False) - invoice_instance.user = user - invoice_instance.save() - new_purchase = Vente.objects.create( - facture=invoice_instance, - name="solde", - prix=invoice.cleaned_data['montant'], - number=1 - ) - new_purchase.save() - messages.success( - request, - _("Balance successfully updated.") - ) - return redirect(reverse('cotisations:index')) - return form({ - 'factureform': invoice, - 'action_name': _("Edit") - }, 'cotisations/facture.html', request) + }, 'cotisations/delete.html', request) @login_required @@ -419,8 +366,9 @@ def add_article(request): return redirect(reverse('cotisations:index-article')) return form({ 'factureform': article, - 'action_name': _("Add") - }, 'cotisations/facture.html', request) + 'action_name': _("Add"), + 'title': _("New article") + }, 'cotisations/facture.html', request) @login_required @@ -440,8 +388,9 @@ def edit_article(request, article_instance, **_kwargs): return redirect(reverse('cotisations:index-article')) return form({ 'factureform': article, - 'action_name': _('Edit') - }, 'cotisations/facture.html', request) + 'action_name': _('Edit'), + 'title': _("Edit article") + }, 'cotisations/facture.html', request) @login_required @@ -461,8 +410,9 @@ def del_article(request, instances): return redirect(reverse('cotisations:index-article')) return form({ 'factureform': article, - 'action_name': _("Delete") - }, 'cotisations/facture.html', request) + 'action_name': _("Delete"), + 'title': _("Delete article") + }, 'cotisations/facture.html', request) # TODO : change paiement to payment @@ -472,9 +422,15 @@ def add_paiement(request): """ View used to add a payment method. """ - payment = PaiementForm(request.POST or None) - if payment.is_valid(): - payment.save() + payment = PaiementForm(request.POST or None, prefix='payment') + payment_method = payment_method_factory( + payment.instance, + request.POST or None, + prefix='payment_method' + ) + if payment.is_valid() and payment_method.is_valid(): + payment = payment.save() + payment_method.save(payment) messages.success( request, _("The payment method has been successfully created.") @@ -482,8 +438,10 @@ def add_paiement(request): return redirect(reverse('cotisations:index-paiement')) return form({ 'factureform': payment, - 'action_name': _("Add") - }, 'cotisations/facture.html', request) + 'payment_method': payment_method, + 'action_name': _("Add"), + 'title': _("New payment method") + }, 'cotisations/facture.html', request) # TODO : chnage paiement to Payment @@ -493,19 +451,34 @@ def edit_paiement(request, paiement_instance, **_kwargs): """ View used to edit a payment method. """ - payment = PaiementForm(request.POST or None, instance=paiement_instance) - if payment.is_valid(): - if payment.changed_data: - payment.save() - messages.success( - request, - _("The payement method has been successfully edited.") - ) + payment = PaiementForm( + request.POST or None, + instance=paiement_instance, + prefix="payment" + ) + payment_method = payment_method_factory( + paiement_instance, + request.POST or None, + prefix='payment_method', + creation=False + ) + + if payment.is_valid() and \ + (payment_method is None or payment_method.is_valid()): + payment.save() + if payment_method is not None: + payment_method.save() + messages.success( + request, + _("The payement method has been successfully edited.") + ) return redirect(reverse('cotisations:index-paiement')) return form({ 'factureform': payment, - 'action_name': _("Edit") - }, 'cotisations/facture.html', request) + 'payment_method': payment_method, + 'action_name': _("Edit"), + 'title': _("Edit payment method") + }, 'cotisations/facture.html', request) # TODO : change paiement to payment @@ -539,8 +512,9 @@ def del_paiement(request, instances): return redirect(reverse('cotisations:index-paiement')) return form({ 'factureform': payment, - 'action_name': _("Delete") - }, 'cotisations/facture.html', request) + 'action_name': _("Delete"), + 'title': _("Delete payment method") + }, 'cotisations/facture.html', request) # TODO : change banque to bank @@ -560,8 +534,9 @@ def add_banque(request): return redirect(reverse('cotisations:index-banque')) return form({ 'factureform': bank, - 'action_name': _("Add") - }, 'cotisations/facture.html', request) + 'action_name': _("Add"), + 'title': _("New bank") + }, 'cotisations/facture.html', request) # TODO : change banque to bank @@ -582,8 +557,9 @@ def edit_banque(request, banque_instance, **_kwargs): return redirect(reverse('cotisations:index-banque')) return form({ 'factureform': bank, - 'action_name': _("Edit") - }, 'cotisations/facture.html', request) + 'action_name': _("Edit"), + 'title': _("Edit bank") + }, 'cotisations/facture.html', request) # TODO : chnage banque to bank @@ -617,8 +593,9 @@ def del_banque(request, instances): return redirect(reverse('cotisations:index-banque')) return form({ 'factureform': bank, - 'action_name': _("Delete") - }, 'cotisations/facture.html', request) + 'action_name': _("Delete"), + 'title': _("Delete bank") + }, 'cotisations/facture.html', request) # TODO : change facture to invoice @@ -659,7 +636,7 @@ def control(request): return render(request, 'cotisations/control.html', { 'facture_list': invoice_list, 'controlform': control_invoices_form - }) + }) @login_required @@ -672,7 +649,7 @@ def index_article(request): article_list = Article.objects.order_by('name') return render(request, 'cotisations/index_article.html', { 'article_list': article_list - }) + }) # TODO : change paiement to payment @@ -685,7 +662,7 @@ def index_paiement(request): payment_list = Paiement.objects.order_by('moyen') return render(request, 'cotisations/index_paiement.html', { 'paiement_list': payment_list - }) + }) # TODO : change banque to bank @@ -698,7 +675,7 @@ def index_banque(request): bank_list = Banque.objects.order_by('name') return render(request, 'cotisations/index_banque.html', { 'banque_list': bank_list - }) + }) @login_required @@ -719,156 +696,62 @@ def index(request): invoice_list = re2o_paginator(request, invoice_list, pagination_number) return render(request, 'cotisations/index.html', { 'facture_list': invoice_list - }) + }) -# TODO : merge this function with new_facture() which is nearly the same -# TODO : change facture to invoice +# TODO : change solde to balance @login_required -def new_facture_solde(request, userid): +@can_edit(User) +def credit_solde(request, user, **_kwargs): """ - View called to create a new invoice when using the balance to pay. - Currently, send the list of available articles for the user along with - a formset of a new invoice (based on the `:forms:NewFactureForm()` form. - A bit of JS is used in the template to add articles in a fancier way. - If everything is correct, save each one of the articles, save the - purchase object associated and finally the newly created invoice. - - TODO : The whole verification process should be moved to the model. This - function should only act as a dumb interface between the model and the - user. + View used to edit the balance of a user. + Can be use either to increase or decrease a user's balance. """ - user = request.user - invoice = Facture(user=user) - payment, _created = Paiement.objects.get_or_create(moyen='Solde') - invoice.paiement = payment - # The template needs the list of articles (for the JS part) - 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 - ) + try: + balance = find_payment_method(Paiement.objects.get(is_balance=True)) + except Paiement.DoesNotExist: + credit_allowed = False else: - article_formset = formset_factory(SelectUserArticleForm)( - request.POST or None + credit_allowed = ( + balance is not None + and balance.can_credit_balance(request.user) ) - - if article_formset.is_valid(): - articles = article_formset - # Check if at leat one article has been selected - if any(art.cleaned_data for art in articles): - user_balance = OptionalUser.get_cached_value('user_solde') - negative_balance = OptionalUser.get_cached_value('solde_negatif') - # If the paiement using balance has been activated, - # checking that the total price won't get the user under - # the authorized minimum (negative_balance) - if user_balance: - total_price = 0 - for art_item in articles: - if art_item.cleaned_data: - total_price += art_item.cleaned_data['article']\ - .prix*art_item.cleaned_data['quantity'] - if float(user.solde) - float(total_price) < negative_balance: - messages.error( - request, - _("The balance is too low for this operation.") - ) - return redirect(reverse( - 'users:profil', - kwargs={'userid': userid} - )) - # Saving the invoice - invoice.save() - - # Building a purchase for each article sold - for art_item in articles: - if art_item.cleaned_data: - article = art_item.cleaned_data['article'] - quantity = art_item.cleaned_data['quantity'] - new_purchase = Vente.objects.create( - facture=invoice, - name=article.name, - prix=article.prix, - type_cotisation=article.type_cotisation, - duration=article.duration, - number=quantity - ) - new_purchase.save() - - # 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 successfully \ - 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 successuflly created.") - ) - return redirect(reverse( - 'users:profil', - kwargs={'userid': userid} - )) + if not credit_allowed: messages.error( request, - _("You need to choose at least one article.") + _("You are not allowed to credit your balance.") ) return redirect(reverse( 'users:profil', - kwargs={'userid': userid} + kwargs={'userid': user.id} )) - return form({ - 'venteform': article_formset, - 'articlelist': article_list - }, 'cotisations/new_facture_solde.html', request) - - -# TODO : change recharge to refill -@login_required -def recharge(request): - """ - View used to refill the balance by using online payment. - """ - if AssoOption.get_cached_value('payment') == 'NONE': - messages.error( - request, - _("Online payment is disabled.") - ) - return redirect(reverse( - 'users:profil', - kwargs={'userid': request.user.id} - )) refill_form = RechargeForm(request.POST or None, user=request.user) if refill_form.is_valid(): - invoice = Facture(user=request.user) - payment, _created = Paiement.objects.get_or_create( - moyen='Rechargement en ligne' - ) - invoice.paiement = payment - invoice.valid = False - invoice.save() - purchase = Vente.objects.create( - facture=invoice, - name='solde', - prix=refill_form.cleaned_data['value'], - number=1 - ) - purchase.save() - content = online_payment.PAYMENT_SYSTEM[ - AssoOption.get_cached_value('payment') - ](invoice, request) - return render(request, 'cotisations/payment.html', content) + price = refill_form.cleaned_data['value'] + invoice = Facture(user=user) + invoice.paiement = refill_form.cleaned_data['payment'] + p = find_payment_method(invoice.paiement) + if hasattr(p, 'check_price'): + price_ok, msg = p.check_price(price, user) + refill_form.add_error(None, msg) + else: + price_ok = True + if price_ok: + invoice.valid = True + invoice.save() + Vente.objects.create( + facture=invoice, + name='solde', + prix=refill_form.cleaned_data['value'], + number=1 + ) + return invoice.paiement.end_payment(invoice, request) + p = get_object_or_404(Paiement, is_balance=True) return form({ - 'rechargeform': refill_form - }, 'cotisations/recharge.html', request) + 'factureform': refill_form, + 'balance': request.user.solde, + 'title': _("Refill your balance"), + 'action_name': _("Pay"), + 'max_balance': p.payment_method.maximum_balance, + }, 'cotisations/facture.html', request) diff --git a/preferences/forms.py b/preferences/forms.py index afe111a2..477d4d57 100644 --- a/preferences/forms.py +++ b/preferences/forms.py @@ -38,6 +38,7 @@ from .models import ( Service ) + class EditOptionalUserForm(ModelForm): """Formulaire d'édition des options de l'user. (solde, telephone..)""" class Meta: @@ -54,13 +55,6 @@ class EditOptionalUserForm(ModelForm): self.fields['is_tel_mandatory'].label = ( 'Exiger un numéro de téléphone' ) - self.fields['user_solde'].label = ( - 'Activation du solde pour les utilisateurs' - ) - self.fields['max_solde'].label = 'Solde maximum' - self.fields['min_online_payment'].label = ( - 'Montant de rechargement minimum en ligne' - ) self.fields['self_adhesion'].label = 'Auto inscription' diff --git a/preferences/migrations/0035_optionaluser_allow_self_subscription.py b/preferences/migrations/0035_optionaluser_allow_self_subscription.py new file mode 100644 index 00000000..5fc45714 --- /dev/null +++ b/preferences/migrations/0035_optionaluser_allow_self_subscription.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-06-17 15:12 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('preferences', '0034_auto_20180416_1120'), + ] + + operations = [ + migrations.AddField( + model_name='optionaluser', + name='allow_self_subscription', + field=models.BooleanField(default=False, help_text="Autoriser les utilisateurs à cotiser par eux mêmes via les moyens de paiement permettant l'auto-cotisation."), + ), + ] diff --git a/preferences/migrations/0039_auto_20180115_0003.py b/preferences/migrations/0039_auto_20180115_0003.py index 3dbe2b4c..f8da5c27 100644 --- a/preferences/migrations/0039_auto_20180115_0003.py +++ b/preferences/migrations/0039_auto_20180115_0003.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals from django.db import migrations, models -import preferences.aes_field class Migration(migrations.Migration): diff --git a/preferences/migrations/0040_auto_20180129_1745.py b/preferences/migrations/0040_auto_20180129_1745.py index dc7800f4..7e657079 100644 --- a/preferences/migrations/0040_auto_20180129_1745.py +++ b/preferences/migrations/0040_auto_20180129_1745.py @@ -3,7 +3,10 @@ from __future__ import unicode_literals from django.db import migrations, models -import preferences.aes_field +try: + import preferences.aes_field as aes_field +except ImportError: + import re2o.aes_field as aes_field class Migration(migrations.Migration): @@ -16,7 +19,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='assooption', name='payment_pass', - field=preferences.aes_field.AESEncryptedField(blank=True, max_length=255, null=True), + field=aes_field.AESEncryptedField(blank=True, max_length=255, null=True), ), migrations.AlterField( model_name='assooption', diff --git a/preferences/migrations/0044_remove_payment_pass.py b/preferences/migrations/0044_remove_payment_pass.py new file mode 100644 index 00000000..23d45145 --- /dev/null +++ b/preferences/migrations/0044_remove_payment_pass.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-07-05 13:40 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('preferences', '0035_optionaluser_allow_self_subscription'), + ] + + operations = [ + migrations.RemoveField( + model_name='assooption', + name='payment_pass', + ), + ] + diff --git a/preferences/migrations/0045_remove_unused_payment_fields.py b/preferences/migrations/0045_remove_unused_payment_fields.py new file mode 100644 index 00000000..6944f58c --- /dev/null +++ b/preferences/migrations/0045_remove_unused_payment_fields.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-07-05 13:40 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('preferences', '0044_remove_payment_pass'), + ('cotisations', '0030_custom_payment'), + ] + + operations = [ + migrations.RemoveField( + model_name='assooption', + name='payment', + ), + migrations.RemoveField( + model_name='assooption', + name='payment_id', + ), + migrations.RemoveField( + model_name='optionaluser', + name='allow_self_subscription', + ), + migrations.RemoveField( + model_name='optionaluser', + name='max_solde', + ), + migrations.RemoveField( + model_name='optionaluser', + name='min_online_payment', + ), + migrations.RemoveField( + model_name='optionaluser', + name='solde_negatif', + ), + migrations.RemoveField( + model_name='optionaluser', + name='user_solde', + ), + ] diff --git a/preferences/models.py b/preferences/models.py index 560fa30f..d39b9f8e 100644 --- a/preferences/models.py +++ b/preferences/models.py @@ -31,12 +31,9 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.core.cache import cache -import cotisations.models import machines.models from re2o.mixins import AclMixin -from .aes_field import AESEncryptedField - class PreferencesModel(models.Model): """ Base object for the Preferences objects @@ -67,22 +64,6 @@ class OptionalUser(AclMixin, PreferencesModel): PRETTY_NAME = "Options utilisateur" is_tel_mandatory = models.BooleanField(default=True) - user_solde = models.BooleanField(default=False) - solde_negatif = models.DecimalField( - max_digits=5, - decimal_places=2, - default=0 - ) - max_solde = models.DecimalField( - max_digits=5, - decimal_places=2, - default=50 - ) - min_online_payment = models.DecimalField( - max_digits=5, - decimal_places=2, - default=10 - ) gpg_fingerprint = models.BooleanField(default=True) all_can_create_club = models.BooleanField( default=False, @@ -108,14 +89,6 @@ class OptionalUser(AclMixin, PreferencesModel): ("view_optionaluser", "Peut voir les options de l'user"), ) - def clean(self): - """Creation du mode de paiement par solde""" - if self.user_solde: - p = cotisations.models.Paiement.objects.filter(moyen="Solde") - if not len(p): - c = cotisations.models.Paiement(moyen="Solde") - c.save() - @receiver(post_save, sender=OptionalUser) def optionaluser_post_save(**kwargs): @@ -294,25 +267,6 @@ class AssoOption(AclMixin, PreferencesModel): blank=True, null=True ) - PAYMENT = ( - ('NONE', 'NONE'), - ('COMNPAY', 'COMNPAY'), - ) - payment = models.CharField( - max_length=255, - choices=PAYMENT, - default='NONE', - ) - payment_id = models.CharField( - max_length=255, - default='', - blank=True - ) - payment_pass = AESEncryptedField( - max_length=255, - null=True, - blank=True, - ) description = models.TextField( null=True, blank=True, diff --git a/preferences/templates/preferences/display_preferences.html b/preferences/templates/preferences/display_preferences.html index 99e3e14f..09395b21 100644 --- a/preferences/templates/preferences/display_preferences.html +++ b/preferences/templates/preferences/display_preferences.html @@ -31,46 +31,30 @@ with this program; if not, write to the Free Software Foundation, Inc., {% block content %}

Préférences utilisateur

- + Editer - +

- - - - - {% if useroptions.user_solde %} - - - {% endif %} - - + + - {% if useroptions.user_solde %} - - - - - - - {% endif %} - + - - - + + +
Téléphone obligatoirement requis {{ useroptions.is_tel_mandatory }}Activation du solde pour les utilisateurs{{ useroptions.user_solde }}
Champ gpg fingerprint {{ useroptions.gpg_fingerprint }}Solde négatif{{ useroptions.solde_negatif }}
Creations d'adhérents par tous {{ useroptions.all_can_create_adherent }}Creations de clubs par tous{{ useroptions.all_can_create_club }}Creations de clubs par tous{{ useroptions.all_can_create_club }}
Solde maximum{{ useroptions.max_solde }}Montant minimal de rechargement en ligne{{ useroptions.min_online_payment }}
Auto inscriptionAuto inscription {{ useroptions.self_adhesion }}Shell par défaut des utilisateurs{{ useroptions.shell_default }}
Shell par défaut des utilisateurs{{ useroptions.shell_default }}

Préférences machines

@@ -91,11 +75,11 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{{ machineoptions.max_lambdauser_aliases }} Support de l'ipv6 {{ machineoptions.ipv6_mode }}
Creation de machines
Creation de machines {{ machineoptions.create_machine }}

Préférences topologie

@@ -108,7 +92,7 @@ with this program; if not, write to the Free Software Foundation, Inc., Politique générale de placement de vlan {{ topologieoptions.radius_general_policy }} - Ce réglage défini la politique vlan après acceptation radius : soit sur le vlan de la plage d'ip de la machine, soit sur un vlan prédéfini dans "Vlan où placer les machines après acceptation RADIUS" + Ce réglage défini la politique vlan après acceptation radius : soit sur le vlan de la plage d'ip de la machine, soit sur un vlan prédéfini dans "Vlan où placer les machines après acceptation RADIUS" @@ -144,12 +128,12 @@ with this program; if not, write to the Free Software Foundation, Inc., Temps avant expiration du lien de reinitialisation de mot de passe (en heures) {{ generaloptions.req_expire_hrs }} - + Message global affiché sur le site {{ generaloptions.general_message }} Résumé des CGU {{ generaloptions.GTU_sum_up }} - + CGU {{generaloptions.GTU}} @@ -171,8 +155,8 @@ with this program; if not, write to the Free Software Foundation, Inc., Adresse - {{ assooptions.adresse1 }}
- {{ assooptions.adresse2 }} + {{ assooptions.adresse1 }}
+ {{ assooptions.adresse2 }} Contact mail {{ assooptions.contact }} @@ -185,13 +169,9 @@ with this program; if not, write to the Free Software Foundation, Inc., Objet utilisateur de l'association {{ assooptions.utilisateur_asso }} - Moyen de paiement automatique - {{ assooptions.payment }} - - - Description de l'association - {{ assooptions.description | safe }} - + Description de l'association + {{ assooptions.description | safe }} +

Messages personalisé dans les mails

@@ -205,7 +185,7 @@ with this program; if not, write to the Free Software Foundation, Inc., Mail de bienvenue (Français) {{ mailmessageoptions.welcome_mail_fr | safe }} - + Mail de bienvenue (Anglais) {{ mailmessageoptions.welcome_mail_en | safe }} diff --git a/preferences/aes_field.py b/re2o/aes_field.py similarity index 72% rename from preferences/aes_field.py rename to re2o/aes_field.py index 302aa82b..2720f5af 100644 --- a/preferences/aes_field.py +++ b/re2o/aes_field.py @@ -7,6 +7,7 @@ # Copyright © 2017 Goulven Kermarec # Copyright © 2017 Augustin Lemesle # Copyright © 2018 Maël Kervella +# 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 @@ -22,10 +23,7 @@ # 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 """ @@ -36,6 +34,7 @@ from random import choice from Crypto.Cipher import AES from django.db import models +from django import forms from django.conf import settings EOD = '`%EofD%`' # This should be something that will not occur in strings @@ -66,18 +65,35 @@ def decrypt(key, s): return ss.split(bytes(EOD, 'utf-8'))[0] +class AESEncryptedFormField(forms.CharField): + widget = forms.PasswordInput(render_value=True) + + 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))) + setattr(instance, self.name, binascii.b2a_base64( + encrypt(settings.AES_KEY, data)).decode('utf-8')) def to_python(self, value): if value is None: return None - return decrypt(settings.AES_KEY, - binascii.a2b_base64(value)).decode('utf-8') + try: + return decrypt(settings.AES_KEY, + binascii.a2b_base64(value)).decode('utf-8') + except Exception as e: + raise ValueError(value) + + def from_db_value(self, value, *args, **kwargs): + if value is None: + return value + try: + return decrypt(settings.AES_KEY, + binascii.a2b_base64(value)).decode('utf-8') + except Exception as e: + raise ValueError(value) def get_prep_value(self, value): if value is None: @@ -85,4 +101,9 @@ class AESEncryptedField(models.CharField): return binascii.b2a_base64(encrypt( settings.AES_KEY, value - )) + )).decode('utf-8') + + def formfield(self, **kwargs): + defaults = {'form_class': AESEncryptedFormField} + defaults.update(kwargs) + return super().formfield(**defaults) diff --git a/topologie/migrations/0030_auto_20171004_0235.py b/topologie/migrations/0030_auto_20171004_0235.py index 83f3b022..ac3ceeba 100644 --- a/topologie/migrations/0030_auto_20171004_0235.py +++ b/topologie/migrations/0030_auto_20171004_0235.py @@ -10,6 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ('topologie', '0029_auto_20171002_0334'), + ('machines', '0049_vlan'), ] operations = [ diff --git a/users/models.py b/users/models.py index 6c6837d6..4a10dd90 100644 --- a/users/models.py +++ b/users/models.py @@ -426,36 +426,31 @@ class User(RevMixin, FieldPermissionModelMixin, AbstractBaseUser, @cached_property def solde(self): - """ Renvoie le solde d'un user. Vérifie que l'option solde est - activé, retourne 0 sinon. + """ Renvoie le solde d'un user. Somme les crédits de solde et retire les débit payés par solde""" - user_solde = OptionalUser.get_cached_value('user_solde') - if user_solde: - solde_objects = Paiement.objects.filter(moyen='Solde') - somme_debit = Vente.objects.filter( - facture__in=Facture.objects.filter( - user=self, - paiement__in=solde_objects, - valid=True - ) - ).aggregate( - total=models.Sum( - models.F('prix')*models.F('number'), - output_field=models.FloatField() - ) - )['total'] or 0 - somme_credit = Vente.objects.filter( - facture__in=Facture.objects.filter(user=self, valid=True), - name="solde" - ).aggregate( - total=models.Sum( - models.F('prix')*models.F('number'), - output_field=models.FloatField() - ) - )['total'] or 0 - return somme_credit - somme_debit - else: - return 0 + solde_objects = Paiement.objects.filter(is_balance=True) + somme_debit = Vente.objects.filter( + facture__in=Facture.objects.filter( + user=self, + paiement__in=solde_objects, + valid=True + ) + ).aggregate( + total=models.Sum( + models.F('prix')*models.F('number'), + output_field=models.FloatField() + ) + )['total'] or 0 + somme_credit = Vente.objects.filter( + facture__in=Facture.objects.filter(user=self, valid=True), + name="solde" + ).aggregate( + total=models.Sum( + models.F('prix')*models.F('number'), + output_field=models.FloatField() + ) + )['total'] or 0 + return somme_credit - somme_debit def user_interfaces(self, active=True): """ Renvoie toutes les interfaces dont les machines appartiennent à diff --git a/users/templates/users/profil.html b/users/templates/users/profil.html index abba61a2..c0cccb14 100644 --- a/users/templates/users/profil.html +++ b/users/templates/users/profil.html @@ -33,13 +33,11 @@ un {{ users.class_name | lower}}{% else %}active{% else %}désactivée{% endif %}.

{% if user_solde %} -

Votre solde est de {{ user.solde }}€. -{% if allow_online_payment %} - +

Votre solde est de {{ users.solde }}€. + Recharger -{% endif %}

{% endif %} @@ -166,8 +164,8 @@ non adhérent{% endif %} et votre connexion est {% if users.has_access %} Solde {{ users.solde }} € - {% if allow_online_payment %} - + {% if user_solde %} + Recharger @@ -283,12 +281,8 @@ non adhérent{% endif %} et votre connexion est {% if users.has_access %} Modifier le solde - {% endif%}{% acl_else %} - {% if user_solde %} - - - Ajouter une cotisation par solde{% endif %}{% acl_end %} - + {% endif%} + {% acl_end %}
{% if facture_list %} diff --git a/users/views.py b/users/views.py index 4072e6a7..0c04a2d9 100644 --- a/users/views.py +++ b/users/views.py @@ -49,9 +49,9 @@ from django.views.decorators.csrf import csrf_exempt from rest_framework.renderers import JSONRenderer from reversion import revisions as reversion -from cotisations.models import Facture +from cotisations.models import Facture, Paiement from machines.models import Machine -from preferences.models import OptionalUser, GeneralOption, AssoOption +from preferences.models import GeneralOption from re2o.views import form from re2o.utils import ( all_has_access, @@ -67,6 +67,7 @@ from re2o.acl import ( can_view_all, can_change ) +from cotisations.utils import find_payment_method from .serializers import MailingSerializer, MailingMemberSerializer from .models import ( @@ -246,7 +247,8 @@ def state(request, user, userid): @can_edit(User, 'groups') def groups(request, user, userid): """ View to edit the groups of a user """ - group_form = GroupForm(request.POST or None, instance=user, user=request.user) + group_form = GroupForm(request.POST or None, + instance=user, user=request.user) if group_form.is_valid(): if group_form.changed_data: group_form.save() @@ -404,23 +406,23 @@ def edit_ban(request, ban_instance, **_kwargs): request ) + @login_required @can_delete(Ban) def del_ban(request, ban, **_kwargs): - """ Supprime un banissement""" - if request.method == "POST": - ban.delete() - messages.success(request, "Le banissement a été supprimé") - return redirect(reverse( - 'users:profil', - kwargs={'userid': str(ban.user.id)} - )) - return form( - {'objet': ban, 'objet_name': 'ban'}, - 'users/delete.html', - request - ) - + """ Supprime un banissement""" + if request.method == "POST": + ban.delete() + messages.success(request, "Le banissement a été supprimé") + return redirect(reverse( + 'users:profil', + kwargs={'userid': str(ban.user.id)} + )) + return form( + {'objet': ban, 'objet_name': 'ban'}, + 'users/delete.html', + request + ) @login_required @@ -481,19 +483,20 @@ def edit_whitelist(request, whitelist_instance, **_kwargs): @login_required @can_delete(Whitelist) def del_whitelist(request, whitelist, **_kwargs): - """ Supprime un acces gracieux""" - if request.method == "POST": - whitelist.delete() - messages.success(request, "L'accés gracieux a été supprimé") - return redirect(reverse( - 'users:profil', - kwargs={'userid': str(whitelist.user.id)} - )) - return form( - {'objet': whitelist, 'objet_name': 'whitelist'}, - 'users/delete.html', - request - ) + """ Supprime un acces gracieux""" + if request.method == "POST": + whitelist.delete() + messages.success(request, "L'accés gracieux a été supprimé") + return redirect(reverse( + 'users:profil', + kwargs={'userid': str(whitelist.user.id)} + )) + return form( + {'objet': whitelist, 'objet_name': 'whitelist'}, + 'users/delete.html', + request + ) + @login_required @can_create(School) @@ -852,7 +855,7 @@ def mon_profil(request): return redirect(reverse( 'users:profil', kwargs={'userid': str(request.user.id)} - )) + )) @login_required @@ -896,20 +899,26 @@ def profil(request, users, **_kwargs): request.GET.get('order'), SortTable.USERS_INDEX_WHITE ) - user_solde = OptionalUser.get_cached_value('user_solde') - allow_online_payment = AssoOption.get_cached_value('payment') != 'NONE' + try: + balance = find_payment_method(Paiement.objects.get(is_balance=True)) + except Paiement.DoesNotExist: + user_solde = False + else: + user_solde = ( + balance is not None + and balance.can_credit_balance(request.user) + ) return render( request, 'users/profil.html', { 'users': users, 'machines_list': machines, - 'nb_machines' : nb_machines, + 'nb_machines': nb_machines, 'facture_list': factures, 'ban_list': bans, 'white_list': whitelists, 'user_solde': user_solde, - 'allow_online_payment': allow_online_payment, } ) @@ -974,6 +983,7 @@ def process_passwd(request, req): class JSONResponse(HttpResponse): """ Framework Rest """ + def __init__(self, data, **kwargs): content = JSONRenderer().render(data) kwargs['content_type'] = 'application/json'