8
0
Fork 0
mirror of https://gitlab2.federez.net/re2o/re2o synced 2024-12-23 15:33:45 +00:00

Merge branch 'fix_online_payment' of https://gitlab.federez.net/federez/re2o into fix_online_payment

This commit is contained in:
Gabriel Detraz 2018-07-10 02:01:03 +02:00
commit b669c25f7f
50 changed files with 1720 additions and 864 deletions

View file

@ -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.

View file

@ -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

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -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),
]

View file

@ -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)
]

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -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)
]

View file

@ -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)
]

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -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):
@ -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 = {
@ -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):
"""

View file

@ -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,
]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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']

View file

@ -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}
))

View file

@ -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<invoice_pk>[0-9]+)$',
views.cheque,
name='validate'
)
]

View file

@ -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()
}
)

View file

@ -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

View file

@ -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
"""

View file

@ -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)

View file

@ -0,0 +1,20 @@
from django.conf.urls import url
from . import views
urlpatterns = [
url(
r'^accept/(?P<factureid>[0-9]+)$',
views.accept_payment,
name='accept_payment'
),
url(
r'^refuse/$',
views.refuse_payment,
name='refuse_payment'
),
url(
r'^ipn/$',
views.ipn,
name='ipn'
),
]

View file

@ -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,22 +34,36 @@ 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)
invoice = get_object_or_404(Facture, id=factureid)
if invoice.valid:
messages.success(
request,
_("The payment of %(amount)s € has been accepted.") % {
'amount': facture.prix()
'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(
@ -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)
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
}

View file

@ -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

View file

@ -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)

View file

@ -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')),
]

View file

@ -33,6 +33,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<th>{% trans "Cotisation type" %}</th>
<th>{% trans "Duration (month)" %}</th>
<th>{% trans "Concerned users" %}</th>
<th>{% trans "Available for everyone" %}</th>
<th></th>
</tr>
</thead>
@ -43,6 +44,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<td>{{ article.type_cotisation }}</td>
<td>{{ article.duration }}</td>
<td>{{ article.type_user }}</td>
<td>{{ article.available_for_everyone }}</td>
<td class="text-right">
{% can_edit article %}
<a class="btn btn-primary btn-sm" role="button" title="{% trans "Edit" %}" href="{% url 'cotisations:edit-article' article.id %}">

View file

@ -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 %}
<h3>{{title}}</h3>
{% endif %}
{% if balance %}
<h4>{% trans "Current balance :" %}{{balance}}€</h4>
{% endif %}
<form class="form" method="post">
{% csrf_token %}
{% if articlesformset %}
@ -57,11 +65,16 @@ with this program; if not, write to the Free Software Foundation, Inc.,
</p>
{% endif %}
{% bootstrap_form factureform %}
{% if payment_method %}
{% bootstrap_form payment_method %}
<div id="paymentMethod"></div>
{% endif %}
{% bootstrap_button action_name button_type='submit' icon='star' %}
</form>
{% if articlesformset %}
{% if articlesformset or payment_method%}
<script type="text/javascript">
{% if articlesformset %}
var prices = {};
{% for article in articles %}
prices[{{ article.id|escapejs }}] = {{ article.prix }};
@ -134,6 +147,34 @@ with this program; if not, write to the Free Software Foundation, Inc.,
}
update_price();
});
{% endif %}
{% if payment_method.templates %}
var TEMPLATES = [
"",
{% for t in payment_method.templates %}
{% if t %}
`{% bootstrap_form t %}`,
{% else %}
"",
{% endif %}
{% endfor %}
];
function update_payment_method_form(){
var method = document.getElementById('paymentMethodSelect').value;
if(method==""){
method=0;
}
else{
method = Number(method);
method += 1;
}
console.log(method);
var html = TEMPLATES[method];
document.getElementById('paymentMethod').innerHTML = html;
}
document.getElementById("paymentMethodSelect").addEventListener("change", update_payment_method_form);
{% endif %}
</script>
{% endif %}

View file

@ -35,14 +35,15 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<form class="form" method="post">
{% csrf_token %}
<h3>{% trans "New invoice" %}</h3>
{% if user.solde %}
<p>
{% blocktrans %}
User's balance : {{ user.solde }} €
{% endblocktrans %}
</p>
{% endif %}
{% bootstrap_form factureform %}
{{ venteform.management_form }}
<!-- TODO: FIXME to include data-type="check" for right option in id_cheque select -->
<h3>{% trans "Invoice's articles" %}</h3>
<div id="form_set" class="form-group">
{% 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();
});

View file

@ -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 %}
<form class="form" method="post">
{% csrf_token %}
<h3>{% trans "New invoice" %}</h3>
{{ venteform.management_form }}
<!-- TODO: FIXME to include data-type="check" for right option in id_cheque select -->
<h3>{% trans "Invoice's articles" %}</h3>
<div id="form_set" class="form-group">
{% for form in venteform.forms %}
<div class='product_to_sell form-inline'>
{% trans "Article" %} : &nbsp;
{% bootstrap_form form label_class='sr-only' %}
&nbsp;
<button class="btn btn-danger btn-sm" id="id_form-0-article-remove" type="button">
<span class="fa fa-times"></span>
</button>
</div>
{% endfor %}
</div>
<input class="btn btn-primary btn-sm" role="button" value="{% trans "Add an article"%}" id="add_one">
<p>
{% blocktrans %}
Total price : <span id="total_price">0,00</span>
{% endblocktrans %}
</p>
{% trans "Confirm" as tr_confirm %}
{% bootstrap_button tr_confirm button_type='submit' icon='star' %}
</form>
<script type="text/javascript">
var prices = {};
{% for article in articlelist %}
prices[{{ article.id|escapejs }}] = {{ article.prix }};
{% endfor %}
var template = `Article : &nbsp;
{% bootstrap_form venteform.empty_form label_class='sr-only' %}
&nbsp;
<button class="btn btn-danger btn-sm" id="id_form-__prefix__-article-remove" type="button">
<span class="fa fa-times"></span>
</button>`
function add_article(){
// Index start at 0 => new_index = number of items
var new_index =
document.getElementsByClassName('product_to_sell').length;
document.getElementById('id_form-TOTAL_FORMS').value ++;
var new_article = document.createElement('div');
new_article.className = 'product_to_sell form-inline';
new_article.innerHTML = template.replace(/__prefix__/g, new_index);
document.getElementById('form_set').appendChild(new_article);
add_listenner_for_id(new_index);
}
function update_price(){
var price = 0;
var product_count =
document.getElementsByClassName('product_to_sell').length;
var article, article_price, quantity;
for (i = 0; i < product_count; ++i){
article = document.getElementById(
'id_form-' + i.toString() + '-article').value;
if (article == '') {
continue;
}
article_price = prices[article];
quantity = document.getElementById(
'id_form-' + i.toString() + '-quantity').value;
price += article_price * quantity;
}
document.getElementById('total_price').innerHTML =
price.toFixed(2).toString().replace('.', ',');
}
function add_listenner_for_id(i){
document.getElementById('id_form-' + i.toString() + '-article')
.addEventListener("change", update_price, true);
document.getElementById('id_form-' + i.toString() + '-article')
.addEventListener("onkeypress", update_price, true);
document.getElementById('id_form-' + i.toString() + '-quantity')
.addEventListener("change", update_price, true);
document.getElementById('id_form-' + i.toString() + '-article-remove')
.addEventListener("click", function(event) {
var article = event.target.parentNode;
article.parentNode.removeChild(article);
document.getElementById('id_form-TOTAL_FORMS').value --;
update_price();
}
)
}
function set_cheque_info_visibility() {
var paiement = document.getElementById("id_Facture-paiement");
var visible = paiement.value == paiement.getAttribute('data-cheque');
p = document.getElementById("id_Facture-paiement");
var display = 'none';
if (visible) {
display = 'block';
}
document.getElementById("id_Facture-cheque")
.parentNode.style.display = display;
document.getElementById("id_Facture-banque")
.parentNode.style.display = display;
}
// Add events manager when DOM is fully loaded
document.addEventListener("DOMContentLoaded", function() {
document.getElementById("add_one")
.addEventListener("click", add_article, true);
var product_count =
document.getElementsByClassName('product_to_sell').length;
for (i = 0; i < product_count; ++i){
add_listenner_for_id(i);
}
document.getElementById("id_Facture-paiement")
.addEventListener("change", set_cheque_info_visibility, true);
set_cheque_info_visibility();
update_price();
});
</script>
{% endblock %}

View file

@ -32,11 +32,14 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% block content %}
<h3>
{% blocktrans %}
Refill of {{ amount }} €
Pay {{ amount }} €
{% endblocktrans %}
</h3>
<form class="form" method="{{ method }}" action="{{ action }}">
{{ content | safe }}
{% if form %}
{% bootstrap_form form %}
{% endif %}
{% trans "Pay" as tr_pay %}
{% bootstrap_button tr_pay button_type='submit' icon='piggy-bank' %}
</form>

View file

@ -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 %}
<h2>{% trans "Balance refill" %}</h2>
<h3>
{% blocktrans %}
Balance : <span class="label label-default">{{ request.user.solde }} €</span>
{% endblocktrans %}
</h3>
<form class="form" method="post">
{% csrf_token %}
{% bootstrap_form rechargeform %}
{% trans "Confirm" as tr_confirm %}
{% bootstrap_button tr_confirm button_type='submit' icon='piggy-bank' %}
</form>
{% endblock %}

View file

@ -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<userid>[0-9]+)$',
views.new_facture_solde,
name='new_facture_solde'
),
url(
r'^recharge/$',
views.recharge,
name='recharge'
),
url(
r'^payment/accept/(?P<factureid>[0-9]+)$',
payment.accept_payment,
name='accept_payment'
),
url(
r'^payment/refuse/$',
payment.refuse_payment,
name='refuse_payment'
),
url(
r'^payment/ipn/$',
payment.ipn,
name='ipn'
),
url(r'^$', views.index, name='index'),
]
] + payment_methods.urls.urlpatterns

32
cotisations/utils.py Normal file
View file

@ -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

View file

@ -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()
}
return new_invoice_instance.paiement.end_payment(
new_invoice_instance,
request
)
# Else, only tell the invoice was created
else:
messages.success(
request,
_("The invoice has been created.")
)
return redirect(reverse(
'users:profil',
kwargs={'userid': userid}
))
messages.error(
request,
_("You need to choose at least one article.")
@ -364,39 +322,6 @@ def del_facture(request, facture, **_kwargs):
}, '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)
@login_required
@can_create(Article)
def add_article(request):
@ -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,6 +413,7 @@ 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)
@ -493,10 +425,21 @@ 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 = 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.")
@ -504,6 +447,7 @@ def edit_paiement(request, paiement_instance, **_kwargs):
return redirect(reverse('cotisations:index-paiement'))
return form({
'factureform': payment,
'payment_method': payment_method,
'action_name': _("Edit")
}, 'cotisations/facture.html', request)
@ -722,153 +666,31 @@ def index(request):
})
# 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)

View file

@ -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."),
),
]

View file

@ -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',
),
]

View file

@ -3,7 +3,6 @@
from __future__ import unicode_literals
from django.db import migrations, models
import preferences.aes_field
class Migration(migrations.Migration):

View file

@ -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',

View file

@ -35,8 +35,6 @@ import cotisations.models
import machines.models
from re2o.mixins import AclMixin
from .aes_field import AESEncryptedField
class PreferencesModel(models.Model):
""" Base object for the Preferences objects
@ -67,22 +65,6 @@ class OptionalUser(AclMixin, PreferencesModel):
PRETTY_NAME = "Options utilisateur"
is_tel_mandatory = models.BooleanField(default=True)
user_solde = models.BooleanField(default=False)
solde_negatif = models.DecimalField(
max_digits=5,
decimal_places=2,
default=0
)
max_solde = models.DecimalField(
max_digits=5,
decimal_places=2,
default=50
)
min_online_payment = models.DecimalField(
max_digits=5,
decimal_places=2,
default=10
)
gpg_fingerprint = models.BooleanField(default=True)
all_can_create_club = models.BooleanField(
default=False,
@ -111,10 +93,7 @@ class OptionalUser(AclMixin, PreferencesModel):
def clean(self):
"""Creation du mode de paiement par solde"""
if self.user_solde:
p = cotisations.models.Paiement.objects.filter(moyen="Solde")
if not len(p):
c = cotisations.models.Paiement(moyen="Solde")
c.save()
cotisations.models.Paiement.objects.get_or_create(is_balance=True)
@receiver(post_save, sender=OptionalUser)
@ -294,25 +273,6 @@ class AssoOption(AclMixin, PreferencesModel):
blank=True,
null=True
)
PAYMENT = (
('NONE', 'NONE'),
('COMNPAY', 'COMNPAY'),
)
payment = models.CharField(
max_length=255,
choices=PAYMENT,
default='NONE',
)
payment_id = models.CharField(
max_length=255,
default='',
blank=True
)
payment_pass = AESEncryptedField(
max_length=255,
null=True,
blank=True,
)
description = models.TextField(
null=True,
blank=True,

View file

@ -40,16 +40,8 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<tr>
<th>Téléphone obligatoirement requis</th>
<td>{{ useroptions.is_tel_mandatory }}</td>
<th>Activation du solde pour les utilisateurs</th>
<td>{{ useroptions.user_solde }}</td>
</tr>
<tr>
<th>Champ gpg fingerprint</th>
<td>{{ useroptions.gpg_fingerprint }}</td>
{% if useroptions.user_solde %}
<th>Solde négatif</th>
<td>{{ useroptions.solde_negatif }}</td>
{% endif %}
</tr>
<tr>
<th>Creations d'adhérents par tous</th>
@ -57,14 +49,6 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<th>Creations de clubs par tous</th>
<td>{{ useroptions.all_can_create_club }}</td>
</tr>
{% if useroptions.user_solde %}
<tr>
<th>Solde maximum</th>
<td>{{ useroptions.max_solde }}</td>
<th>Montant minimal de rechargement en ligne</th>
<td>{{ useroptions.min_online_payment }}</td>
</tr>
{% endif %}
<tr>
<th>Auto inscription</th>
<td>{{ useroptions.self_adhesion }}</td>
@ -185,12 +169,8 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<tr>
<th>Objet utilisateur de l'association</th>
<td>{{ assooptions.utilisateur_asso }}</td>
<th>Moyen de paiement automatique</th>
<td>{{ assooptions.payment }}</td>
</tr>
<tr>
<th>Description de l'association</th>
<td colspan="3">{{ assooptions.description | safe }}</td>
<td>{{ assooptions.description | safe }}</td>
</tr>
</table>

View file

@ -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
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)

View file

@ -426,12 +426,9 @@ 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')
solde_objects = Paiement.objects.filter(is_balance=True)
somme_debit = Vente.objects.filter(
facture__in=Facture.objects.filter(
user=self,
@ -454,8 +451,6 @@ class User(RevMixin, FieldPermissionModelMixin, AbstractBaseUser,
)
)['total'] or 0
return somme_credit - somme_debit
else:
return 0
def user_interfaces(self, active=True):
""" Renvoie toutes les interfaces dont les machines appartiennent à

View file

@ -33,13 +33,11 @@ un {{ users.class_name | lower}}</span>{% else %}<span class="label label-danger
non adhérent</span>{% endif %} et votre connexion est {% if users.has_access %}
<span class="label label-success">active</span>{% else %}<span class="label label-danger">désactivée</span>{% endif %}.</p>
{% if user_solde %}
<p>Votre solde est de <span class="badge">{{ user.solde }}€</span>.
{% if allow_online_payment %}
<a class="btn btn-primary btn-sm" role="button" href="{% url 'cotisations:recharge' %}">
<p>Votre solde est de <span class="badge">{{ users.solde }}€</span>.
<a class="btn btn-primary btn-sm" role="button" href="{% url 'cotisations:credit-solde' users.pk%}">
<i class="fa fa-euro-sign"></i>
Recharger
</a>
{% endif %}
</p>
{% endif %}
@ -166,8 +164,8 @@ non adhérent</span>{% endif %} et votre connexion est {% if users.has_access %}
<tr>
<th>Solde</th>
<td>{{ users.solde }} €
{% if allow_online_payment %}
<a class="btn btn-primary btn-sm" style='float:right' role="button" href="{% url 'cotisations:recharge' %}">
{% if user_solde %}
<a class="btn btn-primary btn-sm" style='float:right' role="button" href="{% url 'cotisations:credit-solde' users.pk%}">
<i class="fa fa-euro-sign"></i>
Recharger
</a>
@ -287,8 +285,10 @@ non adhérent</span>{% endif %} et votre connexion est {% if users.has_access %}
{% if user_solde %}
<a class="btn btn-primary btn-sm" role="button" href="{% url 'cotisations:new_facture_solde' user.id %}">
<i class="fa fa-euro-sign"></i>
Ajouter une cotisation par solde</a>{% endif %}{% acl_end %}
Ajouter une cotisation par solde
</a>
{% endif %}
{% acl_end %}
</div>
<div class="panel-body">
{% if facture_list %}

View file

@ -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,
@ -246,7 +246,8 @@ def state(request, user, userid):
@can_edit(User, 'groups')
def groups(request, user, userid):
""" View to edit the groups of a user """
group_form = GroupForm(request.POST or None, instance=user, user=request.user)
group_form = GroupForm(request.POST or None,
instance=user, user=request.user)
if group_form.is_valid():
if group_form.changed_data:
group_form.save()
@ -404,6 +405,7 @@ def edit_ban(request, ban_instance, **_kwargs):
request
)
@login_required
@can_delete(Ban)
def del_ban(request, ban, **_kwargs):
@ -422,7 +424,6 @@ def del_ban(request, ban, **_kwargs):
)
@login_required
@can_create(Whitelist)
@can_edit(User)
@ -495,6 +496,7 @@ def del_whitelist(request, whitelist, **_kwargs):
request
)
@login_required
@can_create(School)
def add_school(request):
@ -814,7 +816,7 @@ def index_listright(request):
'users/index_listright.html',
{
'listright_list': listright_list,
'superuser_right' : superuser_right,
'superuser_right': superuser_right,
}
)
@ -881,20 +883,20 @@ 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,
'user_solde': user_solde,
'allow_online_payment': allow_online_payment,
}
)
@ -959,6 +961,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'