diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b9b8ca..d846ad09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,3 +94,14 @@ 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 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, pleas go to the wiki. diff --git a/cotisations/forms.py b/cotisations/forms.py index a6647a45..02ee3e3c 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 @@ -42,10 +43,10 @@ from django.core.validators import MinValueValidator from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _l -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): @@ -53,63 +54,32 @@ class NewFactureForm(FormRevMixin, ModelForm): Form used to create a new invoice by using a payment method, a bank and a cheque number. """ + def __init__(self, *args, **kwargs): + user = kwargs.pop('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") 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.objects.filter( + pk__in=map(lambda x: x.pk, Paiement.find_allowed_payments(user)) + ) class Meta: model = Facture - fields = ['paiement', 'banque', 'cheque'] + fields = ['paiement'] def clean(self): cleaned_data = super(NewFactureForm, 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 +97,13 @@ class SelectUserArticleForm( required=True ) + def __init__(self, *args, **kwargs): + user = kwargs.pop('user') + super(SelectUserArticleForm, self).__init__(*args, **kwargs) + self.fields['article'].queryset = Article.objects.filter( + pk__in=map(lambda x: x.pk, Article.find_allowed_articles(user)) + ) + class SelectClubArticleForm(Form): """ @@ -146,6 +123,13 @@ class SelectClubArticleForm(Form): required=True ) + def __init__(self, *args, **kwargs): + user = kwargs.pop('user') + super(SelectClubArticleForm, self).__init__(*args, **kwargs) + self.fields['article'].queryset = Article.objects.filter( + pk__in=map(lambda x: x.pk, Article.find_allowed_articles(user)) + ) + # TODO : change Facture to Invoice class NewFactureFormPdf(Form): @@ -231,17 +215,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 +283,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 +293,41 @@ 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') super(RechargeForm, self).__init__(*args, **kwargs) + self.fields['payment'].empty_label = \ + _("Select a payment method") + self.fields['payment'].queryset = Paiement.objects.filter( + pk__in=map(lambda x: x.pk, + Paiement.find_allowed_payments(self.user)) + ) def clean_value(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'): + balance_method, _created = balance.PaymentMethod\ + .objects.get_or_create() + if value < balance_method.minimum_balance: 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' - ) + 'min_online_amount': balance_method.minimum_balance } ) - if value + self.user.solde > \ - OptionalUser.get_cached_value('max_solde'): + if 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 diff --git a/cotisations/migrations/0030_paiement_allow_self_subscription.py b/cotisations/migrations/0030_paiement_allow_self_subscription.py new file mode 100644 index 00000000..4e9ab60b --- /dev/null +++ b/cotisations/migrations/0030_paiement_allow_self_subscription.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-06-17 14:59 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0029_auto_20180414_2056'), + ] + + operations = [ + migrations.AddField( + model_name='paiement', + name='allow_self_subscription', + field=models.BooleanField(default=False, verbose_name='Is available for self subscription'), + ), + ] diff --git a/cotisations/migrations/0031_article_allow_self_subscription.py b/cotisations/migrations/0031_article_allow_self_subscription.py new file mode 100644 index 00000000..64764edf --- /dev/null +++ b/cotisations/migrations/0031_article_allow_self_subscription.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-06-17 17:13 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0030_paiement_allow_self_subscription'), + ] + + operations = [ + migrations.AddField( + model_name='article', + name='allow_self_subscription', + field=models.BooleanField(default=False, verbose_name='Is available for self subscription'), + ), + ] diff --git a/cotisations/migrations/0032_chequepayment_comnpaypayment.py b/cotisations/migrations/0032_chequepayment_comnpaypayment.py new file mode 100644 index 00000000..9c3fab02 --- /dev/null +++ b/cotisations/migrations/0032_chequepayment_comnpaypayment.py @@ -0,0 +1,65 @@ +# -*- 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() + payment, _created = Payment.objects.get_or_create( + moyen='Rechargement en ligne' + ) + comnpay = ComnpayPayment() + comnpay.payment_user = options.payment_id + comnpay.payment_pass = options.payment_pass + comnpay.payment = payment + comnpay.save() + payment.moyen = "ComnPay" + payment.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0031_article_allow_self_subscription'), + ] + + operations = [ + migrations.RunSQL('update preferences_assooption set payment_pass="" where id=1;'), + 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), + ), + 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)), + ('payment_pass', re2o.aes_field.AESEncryptedField(blank=True, max_length=255, null=True)), + ('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), + ), + migrations.RunPython(add_comnpay), + migrations.RunPython(add_cheque), + ] diff --git a/cotisations/migrations/0033_balancepayment.py b/cotisations/migrations/0033_balancepayment.py new file mode 100644 index 00000000..9308a7b0 --- /dev/null +++ b/cotisations/migrations/0033_balancepayment.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-07-03 13:53 +from __future__ import unicode_literals + +import cotisations.payment_methods.mixins +from django.db import migrations, models +import django.db.models.deletion + + +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') + + solde, _created = Payment.objects.get_or_create(moyen="solde") + balance = BalancePayment() + balance.payment = solde + balance.minimum_balance = options.solde_negatif + balance.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0032_chequepayment_comnpaypayment'), + ] + + operations = [ + 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, 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')), + ], + bases=(cotisations.payment_methods.mixins.PaymentMethodMixin, models.Model), + ), + migrations.RunPython(add_solde) + ] diff --git a/cotisations/migrations/0034_auto_20180703_0929.py b/cotisations/migrations/0034_auto_20180703_0929.py new file mode 100644 index 00000000..aa3ca10f --- /dev/null +++ b/cotisations/migrations/0034_auto_20180703_0929.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-07-03 14:29 +from __future__ import unicode_literals + +import re2o.aes_field +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0033_balancepayment'), + ] + + operations = [ + migrations.AlterField( + model_name='comnpaypayment', + name='payment_credential', + field=models.CharField(blank=True, default='', max_length=255, verbose_name='ComNpay VAD Number'), + ), + migrations.AlterField( + model_name='comnpaypayment', + name='payment_pass', + field=re2o.aes_field.AESEncryptedField(blank=True, max_length=255, null=True, verbose_name='ComNpay Secret Key'), + ), + ] diff --git a/cotisations/migrations/0035_auto_20180703_1005.py b/cotisations/migrations/0035_auto_20180703_1005.py new file mode 100644 index 00000000..3b741d27 --- /dev/null +++ b/cotisations/migrations/0035_auto_20180703_1005.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-07-03 15:05 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0034_auto_20180703_0929'), + ] + + operations = [ + migrations.AlterModelOptions( + name='paiement', + options={'permissions': (('view_paiement', "Can see a payement's details"), ('use', 'Can use a payement')), 'verbose_name': 'Payment method', 'verbose_name_plural': 'Payment methods'}, + ), + migrations.RemoveField( + model_name='paiement', + name='allow_self_subscription', + ), + migrations.AddField( + model_name='paiement', + name='available_for_everyone', + field=models.BooleanField(default=False, verbose_name='Is available for every user'), + ), + ] diff --git a/cotisations/migrations/0036_auto_20180703_1056.py b/cotisations/migrations/0036_auto_20180703_1056.py new file mode 100644 index 00000000..d14f20ad --- /dev/null +++ b/cotisations/migrations/0036_auto_20180703_1056.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-07-03 15:56 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0035_auto_20180703_1005'), + ] + + operations = [ + 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.RemoveField( + model_name='article', + name='allow_self_subscription', + ), + migrations.AddField( + model_name='article', + name='available_for_everyone', + field=models.BooleanField(default=False, verbose_name='Is available for every user'), + ), + ] diff --git a/cotisations/migrations/0037_auto_20180703_1202.py b/cotisations/migrations/0037_auto_20180703_1202.py new file mode 100644 index 00000000..3860ef87 --- /dev/null +++ b/cotisations/migrations/0037_auto_20180703_1202.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-07-03 17:02 +from __future__ import unicode_literals + +from django.db import migrations, models + +def create_max_balance(apps, schema_editor): + OptionalUser = apps.get_model('preferences', 'OptionalUser') + Payment = apps.get_model('cotisations', 'Paiement') + + balance, _created = Payment.objects.get_or_create(moyen='solde') + options, _created = OptionalUser.objects.get_or_create() + + balance.maximum_balance = options.max_solde + balance.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0036_auto_20180703_1056'), + ] + + 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.AddField( + model_name='balancepayment', + name='maximum_balance', + field=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'), + ), + migrations.RunPython(create_max_balance) + ] diff --git a/cotisations/migrations/0038_paiement_is_balance.py b/cotisations/migrations/0038_paiement_is_balance.py new file mode 100644 index 00000000..bea02a17 --- /dev/null +++ b/cotisations/migrations/0038_paiement_is_balance.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-07-04 16:30 +from __future__ import unicode_literals + +from django.db import migrations, models + +def update_balance(apps, _): + Payment = apps.get_model('cotisations', 'Paiement') + try: + balance = Payment.objects.get(moyen="solde") + balance.is_balance = True + balance.save() + except Payment.DoesNotExist: + pass + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0037_auto_20180703_1202'), + ] + + operations = [ + 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'), + ), + migrations.RunPython(update_balance) + ] diff --git a/cotisations/migrations/0039_auto_20180704_1147.py b/cotisations/migrations/0039_auto_20180704_1147.py new file mode 100644 index 00000000..3f982430 --- /dev/null +++ b/cotisations/migrations/0039_auto_20180704_1147.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-07-04 16:47 +from __future__ import unicode_literals + +import cotisations.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0038_paiement_is_balance'), + ] + + operations = [ + migrations.AlterField( + model_name='paiement', + name='is_balance', + field=models.BooleanField(default=False, editable=False, help_text='There should be only one balance payment method.', validators=[cotisations.models.check_no_balance], verbose_name='Is user balance'), + ), + ] diff --git a/cotisations/migrations/0040_auto_20180705_0822.py b/cotisations/migrations/0040_auto_20180705_0822.py new file mode 100644 index 00000000..31b0652a --- /dev/null +++ b/cotisations/migrations/0040_auto_20180705_0822.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-07-05 13:22 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0039_auto_20180704_1147'), + ] + + operations = [ + migrations.AlterField( + model_name='balancepayment', + name='minimum_balance', + field=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'), + ), + ] diff --git a/cotisations/models.py b/cotisations/models.py index c4c6d4af..37d030ea 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,16 @@ 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 + # TODO : change facture to invoice class Facture(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model): @@ -131,7 +137,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 +149,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'] def name(self): """ @@ -157,7 +163,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 +219,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. + """ + nb_payments = len(Paiement.find_allowed_payments(user_request)) + nb_articles = len(Article.find_allowed_articles(user_request)) + return ( + user_request.has_perm('cotisations.add_facture') + or (nb_payments*nb_articles), + _("You don't have the right to create an invoice.") + ) + def __init__(self, *args, **kwargs): super(Facture, self).__init__(*args, **kwargs) self.field_permissions = { @@ -341,12 +363,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 +379,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 +402,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 +523,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 +551,24 @@ 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. + + :param self: The article + :param 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'), + _("You cannot use this Payment.") + ) + + @classmethod + def find_allowed_articles(cls, user): + return [p for p in cls.objects.all() if p.can_buy_article(user)[0]] + class Banque(RevMixin, AclMixin, models.Model): """ @@ -550,6 +595,17 @@ class Banque(RevMixin, AclMixin, models.Model): return self.name +def check_no_balance(): + """This functions checks that no Paiement with is_balance=True exists + + :raises ValidationError: if such a Paiement exists. + """ + p = Paiement.objects.filter(is_balance=True) + if len(p)>0: + raise ValidationError( + _("There are already payment method(s) for user balance") + ) + # TODO : change Paiement to Payment class Paiement(RevMixin, AclMixin, models.Model): """ @@ -576,10 +632,22 @@ class Paiement(RevMixin, AclMixin, models.Model): 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") @@ -604,6 +672,62 @@ class Paiement(RevMixin, AclMixin, models.Model): ) super(Paiement, self).save(*args, **kwargs) + def end_payment(self, invoice, request, use_payment_method=True): + """ + The general way of ending a payment. + + :param invoice: The invoice being created. + :param request: Request sended by the user. + :param use_payment_method: If `self` has an attribute `payment_method`, + returns the result of + `self.payment_method.end_payment(invoice, request)` + + :returns: An `HttpResponse`-like object. + """ + 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': request.user.pseudo, + 'end_date': request.user.end_adhesion() + } + ) + # Else, only tell the invoice was created + else: + messages.success( + request, + _("The invoice has been created.") + ) + return redirect(reverse( + 'users:profil', + kwargs={'userid': invoice.user.pk} + )) + + def can_use_payment(self, user, *_args, **_kwargs): + """Check if a user can use this payment. + + :param self: The payment + :param 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'), + _("You cannot use this Payment.") + ) + + @classmethod + def find_allowed_payments(cls, user): + return [p for p in cls.objects.all() if p.can_use_payment(user)[0]] + 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..4d2875d8 --- /dev/null +++ b/cotisations/payment_methods/balance/models.py @@ -0,0 +1,95 @@ +# -*- 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. + """ + 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 + ) + + 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 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..bdb4e594 --- /dev/null +++ b/cotisations/payment_methods/cheque/models.py @@ -0,0 +1,49 @@ +# -*- 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 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. + """ + 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..47654990 --- /dev/null +++ b/cotisations/payment_methods/cheque/views.py @@ -0,0 +1,68 @@ +# -*- 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 .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 = getattr(invoice.paiement, 'payment_method', None) + if invoice.valid or not isinstance(payment_method, ChequePayment): + messages.error( + request, + _("You cannot pay this invoice with a cheque.") + ) + return redirect(reverse( + 'users:profil', + kwargs={'userid': request.user.pk} + )) + form = 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..d32e0a06 --- /dev/null +++ b/cotisations/payment_methods/comnpay/models.py @@ -0,0 +1,88 @@ +# -*- 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. + """ + 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"), + ) + + 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) diff --git a/cotisations/payment_methods/comnpay/urls.py b/cotisations/payment_methods/comnpay/urls.py new file mode 100644 index 00000000..d7b1e293 --- /dev/null +++ b/cotisations/payment_methods/comnpay/urls.py @@ -0,0 +1,20 @@ +from django.conf.urls import url +from . import views + +urlpatterns = [ + url( + r'^accept/(?P[0-9]+)$', + views.accept_payment, + name='accept_payment' + ), + url( + r'^refuse/$', + views.refuse_payment, + name='refuse_payment' + ), + url( + r'^ipn/$', + views.ipn, + name='ipn' + ), +] diff --git a/cotisations/payment.py b/cotisations/payment_methods/comnpay/views.py similarity index 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..be8df2d1 --- /dev/null +++ b/cotisations/payment_methods/forms.py @@ -0,0 +1,113 @@ +# -*- 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 an empty form. + + :param payment: The payment + :param *args: arguments passed to the form + :param creation: Should be True if you are creating the payment + :param **kwargs: passed to the form + + :returns: A form + """ + 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) + else: + return forms.Form() + + +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 its " + "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) + 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/facture.html b/cotisations/templates/cotisations/facture.html index dc8648ac..081f3f51 100644 --- a/cotisations/templates/cotisations/facture.html +++ b/cotisations/templates/cotisations/facture.html @@ -31,7 +31,15 @@ with this program; if not, write to the Free Software Foundation, Inc., {% block content %} {% bootstrap_form_errors factureform %} - +{% if payment_method %} +{% bootstrap_form_errors payment_method %} +{% endif %} +{% if title %} +

{{title}}

+{% endif %} +{% if balance %} +

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

+{% endif %}
{% csrf_token %} {% if articlesformset %} @@ -57,16 +65,21 @@ with this program; if not, write to the Free Software Foundation, Inc.,

{% endif %} {% bootstrap_form factureform %} + {% if payment_method %} + {% bootstrap_form payment_method %} +
+ {% endif %} {% 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 index 4dab92d3..ddb1ece8 100644 --- a/cotisations/templates/cotisations/new_facture.html +++ b/cotisations/templates/cotisations/new_facture.html @@ -35,14 +35,15 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% csrf_token %}

{% trans "New invoice" %}

+ {% if user.solde %}

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

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

{% trans "Invoice's articles" %}

{% for form in venteform.forms %} @@ -130,20 +131,6 @@ with this program; if not, write to the Free Software Foundation, Inc., ) } - function set_cheque_info_visibility() { - var paiement = document.getElementById("id_Facture-paiement"); - var visible = paiement.value == paiement.getAttribute('data-cheque'); - p = document.getElementById("id_Facture-paiement"); - var display = 'none'; - if (visible) { - display = 'block'; - } - document.getElementById("id_Facture-cheque") - .parentNode.style.display = display; - document.getElementById("id_Facture-banque") - .parentNode.style.display = display; - } - // Add events manager when DOM is fully loaded document.addEventListener("DOMContentLoaded", function() { document.getElementById("add_one") @@ -153,9 +140,6 @@ with this program; if not, write to the Free Software Foundation, Inc., for (i = 0; i < product_count; ++i){ add_listenner_for_id(i); } - document.getElementById("id_Facture-paiement") - .addEventListener("change", set_cheque_info_visibility, true); - set_cheque_info_visibility(); update_price(); }); 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..577ca64f 100644 --- a/cotisations/templates/cotisations/payment.html +++ b/cotisations/templates/cotisations/payment.html @@ -32,11 +32,14 @@ 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 %} + {% 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/views.py b/cotisations/views.py index 47076c8f..aa60aa8b 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 @@ -56,7 +57,7 @@ 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, @@ -70,11 +71,10 @@ 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 @login_required @@ -88,25 +88,28 @@ def new_facture(request, user, userid): 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 = NewFactureForm( + request.POST or None, + instance=invoice, + user=request.user + ) + 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,34 +117,6 @@ 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 @@ -159,28 +134,11 @@ def new_facture(request, user, userid): ) 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 - else: - messages.success( - request, - _("The invoice has been created.") - ) - return redirect(reverse( - 'users:profil', - kwargs={'userid': userid} - )) + return new_invoice_instance.paiement.end_payment( + new_invoice_instance, + request + ) + messages.error( request, _("You need to choose at least one article.") @@ -251,13 +209,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 +258,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 @@ -324,7 +282,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 +299,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 +319,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 @@ -420,7 +345,7 @@ def add_article(request): return form({ 'factureform': article, 'action_name': _("Add") - }, 'cotisations/facture.html', request) + }, 'cotisations/facture.html', request) @login_required @@ -441,7 +366,7 @@ def edit_article(request, article_instance, **_kwargs): return form({ 'factureform': article, 'action_name': _('Edit') - }, 'cotisations/facture.html', request) + }, 'cotisations/facture.html', request) @login_required @@ -462,7 +387,7 @@ def del_article(request, instances): return form({ 'factureform': article, 'action_name': _("Delete") - }, 'cotisations/facture.html', request) + }, 'cotisations/facture.html', request) # TODO : change paiement to payment @@ -472,9 +397,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 +413,9 @@ def add_paiement(request): return redirect(reverse('cotisations:index-paiement')) return form({ 'factureform': payment, + 'payment_method': payment_method, 'action_name': _("Add") - }, 'cotisations/facture.html', request) + }, 'cotisations/facture.html', request) # TODO : chnage paiement to Payment @@ -493,19 +425,31 @@ 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_valid(): + payment.save() + payment_method.save() + messages.success( + request, + _("The payement method has been successfully edited.") + ) return redirect(reverse('cotisations:index-paiement')) return form({ 'factureform': payment, + 'payment_method': payment_method, 'action_name': _("Edit") - }, 'cotisations/facture.html', request) + }, 'cotisations/facture.html', request) # TODO : change paiement to payment @@ -540,7 +484,7 @@ def del_paiement(request, instances): return form({ 'factureform': payment, 'action_name': _("Delete") - }, 'cotisations/facture.html', request) + }, 'cotisations/facture.html', request) # TODO : change banque to bank @@ -561,7 +505,7 @@ def add_banque(request): return form({ 'factureform': bank, 'action_name': _("Add") - }, 'cotisations/facture.html', request) + }, 'cotisations/facture.html', request) # TODO : change banque to bank @@ -583,7 +527,7 @@ def edit_banque(request, banque_instance, **_kwargs): return form({ 'factureform': bank, 'action_name': _("Edit") - }, 'cotisations/facture.html', request) + }, 'cotisations/facture.html', request) # TODO : chnage banque to bank @@ -618,7 +562,7 @@ def del_banque(request, instances): return form({ 'factureform': bank, 'action_name': _("Delete") - }, 'cotisations/facture.html', request) + }, 'cotisations/facture.html', request) # TODO : change facture to invoice @@ -659,7 +603,7 @@ def control(request): return render(request, 'cotisations/control.html', { 'facture_list': invoice_list, 'controlform': control_invoices_form - }) + }) @login_required @@ -672,7 +616,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 +629,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 +642,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 +663,34 @@ 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_create(Facture) +@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 - ) - else: - article_formset = formset_factory(SelectUserArticleForm)( - request.POST or None - ) - - 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} - )) - messages.error( - request, - _("You need to choose at least one article.") - ) - return redirect(reverse( - 'users:profil', - kwargs={'userid': userid} - )) - - 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.paiement = refill_form.cleaned_data['payment'] invoice.valid = False invoice.save() - purchase = Vente.objects.create( + 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) + return invoice.paiement.end_payment(invoice, request) return form({ - 'rechargeform': refill_form - }, 'cotisations/recharge.html', request) + 'factureform': refill_form, + 'balance': request.user.solde, + 'title': _("Refill your balance"), + 'action_name': _("Pay") + }, 'cotisations/facture.html', request) diff --git a/machines/migrations/0083_remove_duplicate_rights.py b/machines/migrations/0083_remove_duplicate_rights.py new file mode 100644 index 00000000..05ad2938 --- /dev/null +++ b/machines/migrations/0083_remove_duplicate_rights.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + +def remove_permission_alias(apps, schema_editor): + Permission = apps.get_model('auth', 'Permission') + for codename in ['add_alias', 'change_alias', 'delete_alias']: + # Retrieve the wrong permission + try: + to_remove = Permission.objects.get( + codename=codename, + content_type__model='domain' + ) + except Permission.DoesNotExist: + # The permission is missing so no problem + pass + else: + to_remove.delete() + + +def remove_permission_text(apps, schema_editor): + Permission = apps.get_model('auth', 'Permission') + for codename in ['add_text', 'change_text', 'delete_text']: + # Retrieve the wrong permission + try: + to_remove = Permission.objects.get( + codename=codename, + content_type__model='txt' + ) + except Permission.DoesNotExist: + # The permission is missing so no problem + pass + else: + to_remove.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('machines', '0082_auto_20180525_2209'), + ] + + operations = [ + migrations.RunPython(remove_permission_text), + migrations.RunPython(remove_permission_alias), + ] 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/0036_auto_20180705_0840.py b/preferences/migrations/0036_auto_20180705_0840.py new file mode 100644 index 00000000..9dc67dac --- /dev/null +++ b/preferences/migrations/0036_auto_20180705_0840.py @@ -0,0 +1,47 @@ +# -*- 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', + ), + migrations.RemoveField( + model_name='assooption', + name='payment_id', + ), + migrations.RemoveField( + model_name='assooption', + name='payment_pass', + ), + 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/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/models.py b/preferences/models.py index 13d0709b..79cf60fc 100644 --- a/preferences/models.py +++ b/preferences/models.py @@ -72,35 +72,8 @@ class OptionalUser(AclMixin, PreferencesModel): activation ou non du solde, autorisation du negatif, fingerprint etc""" PRETTY_NAME = "Options utilisateur" - is_tel_mandatory = models.BooleanField( - default=True, - help_text="Obligation de renseigner le téléphone" - ) - user_solde = models.BooleanField( - default=False, - help_text="Solde pour les users" - ) - solde_negatif = models.DecimalField( - max_digits=5, - decimal_places=2, - default=0, - help_text="Maximum de négatif autorisé" - ) - max_solde = models.DecimalField( - max_digits=5, - decimal_places=2, - default=50, - help_text="Valeur maximum du solde" - ) - min_online_payment = models.DecimalField( - max_digits=5, - decimal_places=2, - default=10, - help_text="Montant minimum pour le rechargement online" - ) - gpg_fingerprint = models.BooleanField( - default=True, - help_text="Gpg fingerprint activée") + is_tel_mandatory = models.BooleanField(default=True) + gpg_fingerprint = models.BooleanField(default=True) all_can_create_club = models.BooleanField( default=False, help_text="Les users peuvent créer un club" @@ -145,13 +118,10 @@ class OptionalUser(AclMixin, PreferencesModel): Vérifie que l'extension mail commence bien par @ """ if self.user_solde: - p = cotisations.models.Paiement.objects.filter(moyen="Solde") - if not len(p): - c = cotisations.models.Paiement(moyen="Solde") - c.save() + cotisations.models.Paiement.objects.get_or_create(is_balance=True) if self.mail_extension[0] != "@": raise ValidationError("L'extension mail doit commencer par un @") - + @receiver(post_save, sender=OptionalUser) def optionaluser_post_save(**kwargs): @@ -470,28 +440,6 @@ class AssoOption(AclMixin, PreferencesModel): null=True, help_text="Utilisateur dans la db correspondant à l'asso" ) - PAYMENT = ( - ('NONE', 'NONE'), - ('COMNPAY', 'COMNPAY'), - ) - payment = models.CharField( - max_length=255, - choices=PAYMENT, - default='NONE', - help_text="Mode de paiement en ligne" - ) - payment_id = models.CharField( - max_length=255, - default='', - blank=True, - help_text="Id de paiement en ligne" - ) - payment_pass = AESEncryptedField( - max_length=255, - null=True, - blank=True, - help_text="Clef de paiement en ligne" - ) description = models.TextField( null=True, blank=True, diff --git a/preferences/templates/preferences/display_preferences.html b/preferences/templates/preferences/display_preferences.html index 3f53386f..d0235081 100644 --- a/preferences/templates/preferences/display_preferences.html +++ b/preferences/templates/preferences/display_preferences.html @@ -31,9 +31,9 @@ with this program; if not, write to the Free Software Foundation, Inc., {% block content %}

Préférences utilisateur

- + Editer - +

Généralités
@@ -50,6 +50,10 @@ with this program; if not, write to the Free Software Foundation, Inc., Shell par défaut des utilisateurs {{ useroptions.shell_default }} + + Champ gpg fingerprint + {{ useroptions.gpg_fingerprint }} + Creations d'adhérents par tous {{ useroptions.all_can_create_adherent }} @@ -103,6 +107,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endfor %} {% endfor %} +

Préférences topologie

@@ -164,6 +169,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endfor %} {% endfor %} +

Données de l'association

@@ -182,6 +188,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endfor %} {% endfor %} +

Messages personalisés dans les mails

@@ -201,6 +208,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% endfor %} {% endfor %} + diff --git a/preferences/aes_field.py b/re2o/aes_field.py similarity index 75% rename from preferences/aes_field.py rename to re2o/aes_field.py index 302aa82b..5708eeef 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,6 +65,10 @@ 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 """ @@ -76,8 +79,22 @@ class AESEncryptedField(models.CharField): def to_python(self, value): if value is None: return None - return decrypt(settings.AES_KEY, + try: + return decrypt(settings.AES_KEY, + binascii.a2b_base64(value)).decode('utf-8') + except Exception as e: + v = decrypt(settings.AES_KEY, binascii.a2b_base64(value)) + raise ValueError(v) + + 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: + v = decrypt(settings.AES_KEY, binascii.a2b_base64(value)) + raise ValueError(v) def get_prep_value(self, value): if value is None: @@ -86,3 +103,8 @@ class AESEncryptedField(models.CharField): settings.AES_KEY, value )) + + def formfield(self, **kwargs): + defaults = {'form_class': AESEncryptedFormField} + defaults.update(kwargs) + return super().formfield(**defaults) diff --git a/users/models.py b/users/models.py index 4e1f4202..b0e56ad1 100644 --- a/users/models.py +++ b/users/models.py @@ -436,36 +436,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 b76e551d..21b3c991 100644 --- a/users/templates/users/profil.html +++ b/users/templates/users/profil.html @@ -27,6 +27,7 @@ with this program; if not, write to the Free Software Foundation, Inc., {% load acl %} {% block title %}Profil{% endblock %} {% block content %} +

Bienvenue {{users.name}} {{ users.surname }}

@@ -244,8 +245,8 @@ with this program; if not, write to the Free Software Foundation, Inc., Solde {{ users.solde }} € - {% if allow_online_payment %} -
+ {% if user_solde %} + Recharger @@ -365,8 +366,10 @@ with this program; if not, write to the Free Software Foundation, Inc., {% if user_solde %} - Ajouter une cotisation par solde{% endif %}{% acl_end %} + Ajouter une cotisation par solde + {% endif %} + {% acl_end %}
{% if facture_list %} diff --git a/users/views.py b/users/views.py index 731a56df..05e77372 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, @@ -249,7 +249,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() @@ -407,23 +408,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 @@ -484,19 +485,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(MailAlias) @@ -902,7 +904,7 @@ def index_listright(request): 'users/index_listright.html', { 'listright_list': listright_list, - 'superuser_right' : superuser_right, + 'superuser_right': superuser_right, } ) @@ -925,7 +927,7 @@ def mon_profil(request): return redirect(reverse( 'users:profil', kwargs={'userid': str(request.user.id)} - )) + )) @login_required @@ -969,15 +971,16 @@ 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' + balance, _created = Paiement.objects.get_or_create(moyen="solde") + user_solde = Facture.can_create(request.user) \ + and balance.can_use_payment(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, @@ -1051,6 +1054,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'