Parcourir la source

gestion des paiements

Élie Bouttier il y a 7 ans
Parent
commit
017c7f0237

+ 9 - 4
adhesions/admin.py

@@ -5,12 +5,13 @@ from django.contrib.contenttypes.models import ContentType
 from django.contrib.auth.models import User as AuthUser, Group
 from django.contrib.auth.models import User as AuthUser, Group
 from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
 from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
 from django.http import HttpResponseRedirect
 from django.http import HttpResponseRedirect
+from django.utils.html import format_html
+from django.core.urlresolvers import reverse
 
 
 from .forms import UserCreationForm
 from .forms import UserCreationForm
 from .models import User, Corporation, Adhesion
 from .models import User, Corporation, Adhesion
 from accounts.models import Profile
 from accounts.models import Profile
 from services.models import Service
 from services.models import Service
-from banking.admin import PaymentInline
 
 
 
 
 ### Inlines
 ### Inlines
@@ -205,18 +206,22 @@ class AdhesionAdmin(AdtSearchMixin, admin.ModelAdmin):
     list_display = ('get_id', 'type', 'get_adherent_link',)
     list_display = ('get_id', 'type', 'get_adherent_link',)
     list_filter = (AdherentTypeFilter, 'active',)
     list_filter = (AdherentTypeFilter, 'active',)
     list_select_related = ('user', 'user__profile', 'corporation',)
     list_select_related = ('user', 'user__profile', 'corporation',)
-    fields = ('id', 'type', 'get_adherent_link',)
-    readonly_fields = ('id', 'type', 'get_adherent_link',)
+    fields = ('id', 'type', 'get_adherent_link', 'get_membership_link',)
+    readonly_fields = ('id', 'type', 'get_adherent_link', 'get_membership_link',)
     search_fields = ('=id', 'notes',) \
     search_fields = ('=id', 'notes',) \
                     + tuple(['user__%s' % f for f in UserAdmin.search_fields if '__' not in f]) \
                     + tuple(['user__%s' % f for f in UserAdmin.search_fields if '__' not in f]) \
                     + tuple(['corporation__%s' % f for f in CorporationAdmin.search_fields if '__' not in f])
                     + tuple(['corporation__%s' % f for f in CorporationAdmin.search_fields if '__' not in f])
-    inlines = (ServiceInline,)#PaymentInline,)
+    inlines = (ServiceInline,)
 
 
     def get_id(self, obj):
     def get_id(self, obj):
         return 'ADT%d' % obj.id
         return 'ADT%d' % obj.id
     get_id.short_description = 'Numéro d’adhérent'
     get_id.short_description = 'Numéro d’adhérent'
     get_id.admin_order_field = 'id'
     get_id.admin_order_field = 'id'
 
 
+    def get_membership_link(self, obj):
+        return format_html(u'<a href="{}">{}</a>', obj.membership.get_absolute_url(), obj.membership)
+    get_membership_link.short_description = 'Cotisation'
+
     def get_actions(self, request):
     def get_actions(self, request):
         actions = super().get_actions(request)
         actions = super().get_actions(request)
         if 'delete_selected' in actions:
         if 'delete_selected' in actions:

+ 38 - 0
adhesions/migrations/0016_adhesion_membership.py

@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.2 on 2017-07-04 22:52
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def create_default_membership(apps, schema_editor):
+    db_alias = schema_editor.connection.alias
+    Adhesion = apps.get_model("adhesions", "Adhesion")
+    RecurringPayment = apps.get_model("banking", "RecurringPayment")
+    for adhesion in Adhesion.objects.using(db_alias).all():
+        if not adhesion.membership:
+            adhesion.membership = RecurringPayment.objects.using(db_alias).create()
+            adhesion.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('banking', '0006_auto_20170705_1825'),
+        ('adhesions', '0015_auto_20170614_2312'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='adhesion',
+            name='membership',
+            field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to='banking.RecurringPayment'),
+        ),
+        migrations.RunPython(create_default_membership),
+        migrations.AlterField(
+            model_name='adhesion',
+            name='membership',
+            field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='banking.RecurringPayment'),
+        ),
+    ]

+ 8 - 14
adhesions/models.py

@@ -7,7 +7,7 @@ from django.core.urlresolvers import reverse
 from django.utils.html import format_html
 from django.utils.html import format_html
 
 
 from djadhere.utils import get_active_filter
 from djadhere.utils import get_active_filter
-from banking.models import Payment
+from banking.models import RecurringPayment
 
 
 
 
 class User(AuthUser):
 class User(AuthUser):
@@ -58,10 +58,6 @@ class Adhesion(models.Model):
     limit = models.Q(app_label='auth', model='user') \
     limit = models.Q(app_label='auth', model='user') \
           | models.Q(app_label='adhesions', model='corporation')
           | models.Q(app_label='adhesions', model='corporation')
     id = models.AutoField(verbose_name='Numéro d’adhérent', primary_key=True, editable=True)
     id = models.AutoField(verbose_name='Numéro d’adhérent', primary_key=True, editable=True)
-    contributions = GenericRelation(Payment,
-                                   content_type_field='reason_type',
-                                   object_id_field='reason_id',
-                                   related_query_name='adhesion')
     created = models.DateTimeField(null=True, blank=True, auto_now_add=True)
     created = models.DateTimeField(null=True, blank=True, auto_now_add=True)
     notes = models.TextField(blank=True, default='')
     notes = models.TextField(blank=True, default='')
     active = models.NullBooleanField(default=None, verbose_name='Adhésion en cours')
     active = models.NullBooleanField(default=None, verbose_name='Adhésion en cours')
@@ -69,19 +65,17 @@ class Adhesion(models.Model):
     user = models.OneToOneField(User, null=True)
     user = models.OneToOneField(User, null=True)
     corporation = models.OneToOneField(Corporation, null=True)
     corporation = models.OneToOneField(Corporation, null=True)
 
 
+    membership = models.OneToOneField(RecurringPayment)
+
+    def save(self, *args, **kwargs):
+        if not self.membership:
+            self.membership = RecurringPayment.objects.create()
+        super().save(*args, **kwargs)
+
     class Meta:
     class Meta:
         verbose_name = 'adhésion'
         verbose_name = 'adhésion'
         ordering = ('id',)
         ordering = ('id',)
 
 
-    @property
-    def contribution(self):
-        try:
-            return self.contributions.exclude(period=0).filter(get_active_filter()).get()
-        except Payment.DoesNotExist:
-            return None
-        # MultipleObjectsReturned non catché volontairement, le filtrage par la méthode clean est censé
-        # empêcher cette possibilité, si cette exception est levé on veut recevoir un mail avec l’erreur !
-
     def is_physical(self):
     def is_physical(self):
         return self.user is not None
         return self.user is not None
 
 

+ 148 - 150
banking/admin.py

@@ -1,172 +1,170 @@
 from django.contrib import admin
 from django.contrib import admin
-from django.contrib.contenttypes.admin import GenericTabularInline
-from django.contrib.contenttypes.models import ContentType
-from django.db.models import Q
+from django.db import models
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
+from django.forms import BaseInlineFormSet
+from django.utils.html import format_html
 
 
 from services.models import ServiceType
 from services.models import ServiceType
-from .models import Payment
+from services.admin import ServiceAdmin
+from adhesions.admin import AdhesionAdmin
+from .models import RecurringPayment, PaymentUpdate
+from .utils import notify_payment_update
 
 
 
 
 ### Inlines
 ### Inlines
 
 
-class PaymentInline(GenericTabularInline):
-    model = Payment
-    ct_field = 'reason_type'
-    ct_fk_field = 'reason_id'
-    extra = 0
-    #max_num = 0
-    fields = ('amount', 'period', 'payment_method', 'start', 'end',)
-    #readonly_fields = ('amount', 'period', 'payment_method', 'start',)
-    verbose_name_plural = 'Contributions'
-
-    #def get_queryset(self, request):
-    #    # Paiement récurrent en cours (sans date de fin)
-    #    return super().get_queryset(request).filter(period__gt=0, end__isnull=True)
-
-    #def has_delete_permission(self, request, obj=None):
-    #    return False
-
-
-#class ValidatedPaymentInline(PaymentInline):
-#    extra = 0
-#    verbose_name_plural = 'Paiements validés'
-#
-#    def get_readonly_fields(self, request, obj=None):
-#        if request.user.has_perm('banking.validate_payment'):
-#            return ()
-#        else:
-#            return self.get_fields(request, obj)
-#
-#    def has_add_permission(self, request):
-#        return False
-#
-#    def has_delete_permission(self, request, obj=None):
-#        return request.user.has_perm('banking.validate_payment')
-#
-#    def get_queryset(self, request):
-#        return super().get_queryset(request).filter(validated=True)
-#
-#
-#class PendingOrNewPaymentInline(PaymentInline):
-#    verbose_name_plural = 'Paiements en attente de validation et nouveaux paiements'
-#
-#    def get_queryset(self, request):
-#        return super().get_queryset(request).filter(validated=False)
-
-
-### Filters
-
 class PaymentTypeFilter(admin.SimpleListFilter):
 class PaymentTypeFilter(admin.SimpleListFilter):
-    title = 'type de paiement'
+    title = 'type'
     parameter_name = 'type'
     parameter_name = 'type'
 
 
     def lookups(self, request, model_admin):
     def lookups(self, request, model_admin):
-        choices = [
-            ('membership', 'Cotisation'),
+        return (
+            ('adhesion', 'Adhésion'),
             ('service', 'Service'),
             ('service', 'Service'),
-        ]
-        service_types = ServiceType.objects.all()
-        if not (request.user.is_superuser or request.user.has_perm('banking.validate_payment')):
-            service_types = service_types.filter(group__in=request.user.groups.all())
-        for stype in service_types:
-            choices.append((stype.pk, 'Service (%s)' % stype.name))
-        return choices
+        )
 
 
     def queryset(self, request, queryset):
     def queryset(self, request, queryset):
-        if self.value() == 'membership':
-            return queryset.filter(reason_type__app_label='adhesions',
-                                   reason_type__model='adhesion')
+        if self.value() == 'adhesion':
+            return queryset.filter(adhesion__isnull=False)
         if self.value() == 'service':
         if self.value() == 'service':
-            return queryset.filter(reason_type__app_label='services',
-                                   reason_type__model='service')
-        try:
-            service_type = ServiceType.objects.get(pk=int(self.value()))
-        except (ValueError, TypeError, ServiceType.DoesNotExist,):
-            return queryset
-        else:
-            return queryset.filter(service__service_type=service_type)
+            return queryset.filter(service__isnull=False)
+
+
+class PaymentStatusFilter(admin.SimpleListFilter):
+    title = 'actif'
+    parameter_name = 'active'
+
+    def lookups(self, request, model_admin):
+        return (
+            (0, 'Inactif'),
+            (1, 'Actif'),
+        )
+
+    def queryset(self, request, queryset):
+        actives = PaymentUpdate.objects.filter(validated=True).order_by('payment', '-start') \
+                        .distinct('payment').exclude(payment_method=PaymentUpdate.STOP) \
+                        .values_list('payment__pk', flat=True)
+        if self.value() == '0':
+            return queryset.exclude(pk__in=actives)
+        if self.value() == '1':
+            return queryset.filter(pk__in=actives)
+
+
+class PendingPaymentFilter(admin.SimpleListFilter):
+    title = 'opérations en attente'
+    parameter_name = 'pending'
+
+    def lookups(self, request, model_admin):
+        return (
+            (0, 'À jour'),
+            (1, 'En attente'),
+        )
+
+    def queryset(self, request, queryset):
+        pending = PaymentUpdate.objects.filter(validated=False).values_list('payment__pk', flat=True)
+        if self.value() == '0':
+            return queryset.exclude(pk__in=pending)
+        if self.value() == '1':
+            return queryset.filter(pk__in=pending)
+
+
+### Inlines
+
+class PendingPaymentUpdateFormSet(BaseInlineFormSet):
+    def save_new(self, form, commit=True):
+        obj = super().save_new(form, commit)
+        notify_payment_update(self.request, obj)
+        return obj
+
+    def save_existing(self, form, instance, commit=True):
+        old = PaymentUpdate.objects.get(pk=instance.pk)
+        notify_payment_update(self.request, instance, old)
+        return super().save_existing(form, instance, commit)
+
+
+class PendingPaymentUpdateInline(admin.TabularInline):
+    model = PaymentUpdate
+    formset = PendingPaymentUpdateFormSet
+    extra = 1
+    verbose_name_plural = 'En attente de saisie bancaire'
+
+    def get_formset(self, request, obj=None, **kwargs):
+        formset = super().get_formset(request, obj, **kwargs)
+        formset.request = request
+        return formset
+
+    def get_queryset(self, request):
+        return super().get_queryset(request).filter(validated=False)
+
+
+class ValidatedPaymentUpdateInline(admin.TabularInline):
+    model = PaymentUpdate
+    verbose_name_plural = 'Historique'
+    max_num = 0
+    fields = ('amount', 'period', 'payment_method', 'start',)
+    readonly_fields = ('amount', 'period', 'payment_method', 'start',)
+
+    def has_delete_permission(self, request, obj=None):
+        return False
+
+    def get_queryset(self, request):
+        return super().get_queryset(request).filter(validated=True)
+
 
 
+### Helpers
 
 
-#### Actions
-#
-#def validate_payment(payment, request, queryset):
-#    queryset.update(validated=True)
+def prefix_search_field(prefix, field):
+    if field[0] == '=':
+        return '=' + prefix + '__' + field[1:]
+    else:
+        return prefix + '__' + field
 
 
 
 
 ### ModelAdmin
 ### ModelAdmin
 
 
-class PaymentAdmin(admin.ModelAdmin):
-    list_display = ('id', 'type_verbose', 'get_adhesion', 'amount', 'period', 'start_display', 'end_display',)
-#    #list_display_links = None
-    list_filter = (PaymentTypeFilter, 'payment_method', 'validated',)
-#
-#    def get_list_display(self, request):
-#        list_display = ()
-#        #if request.user.has_perm('auth.change_user'):
-#        #    list_display += ('adherent_link',)
-#        #else:
-#        #list_display += ('get_adherent',)
-#        list_display += ('payment_type_verbose', 'get_adherent', 'amount',
-#                        'period', 'payment_method', 'start_display', 'end_display', 'validated_display',)
-#        #if request.user.has_perm('banking.validate_payment'):
-#        #    list_display += ('change',)
-#        #else:
-#        #    list_display += ('change_pending',)
-#        return list_display
-#
-    def start_display(self, obj):
-        return obj.start
-    start_display.short_description = 'Début'
-
-    def end_display(self, obj):
-        return obj.end
-    end_display.short_description = 'Fin'
-#
-#    def validated_display(self, obj):
-#        return obj.validated
-#    validated_display.short_description = 'Validé'
-#    validated_display.boolean = True
-#
-#    #def adherent_link(self, obj):
-#    #    adherent = obj.get_adherent()
-#    #    url = reverse('admin:adhesions_adherent_change', args=[adherent.pk])
-#    #    return '<a href="%s">%s</a>' % (url, adherent)
-#    #adherent_link.short_description = 'Adhérent'
-#    #adherent_link.allow_tags = True
-#
-#    def change(self, obj):
-#        url = reverse('admin:banking_payment_change', args=[obj.pk])
-#        return '<a href="%s" class="changelink">Modifier</a>' % url
-#    change.short_description = ''
-#    change.allow_tags = True
-#
-#    def change_pending(self, obj):
-#        if obj.validated:
-#            return '-'
-#        else:
-#            return self.change(obj)
-#    change_pending.short_description = ''
-#    change_pending.allow_tags = True
-#
-#    def get_actions(self, request):
-#        actions = super().get_actions(request)
-#        if request.user.has_perm('banking.validate_payment'):
-#            actions['validate'] = (validate_payment, 'validate', 'Valider les paiements sélectionnés')
-#        return actions
-#
-#    def has_add_permission(self, request):
-#        return False
-#
-#    def has_change_permission(self, request, obj=None):
-#        if obj and not request.user.has_perm('banking.validate_payment'):
-#            return not obj.validated
-#        return True
-#
-#    def has_delete_permission(self, request, obj=None):
-#        if obj and not request.user.has_perm('banking.validate_payment'):
-#            return not obj.validated
-#        return False
-
-
-#admin.site.register(Payment, PaymentAdmin)
+class RecurringPaymentAdmin(admin.ModelAdmin):
+    list_display = ('id', 'payment_type', 'payment_object_link', 'get_status', 'get_last_validated_update', 'get_pending',)
+    list_select_related = ('adhesion', 'service', 'service__service_type',)
+    inlines = (PendingPaymentUpdateInline, ValidatedPaymentUpdateInline,)
+    list_filter = (PaymentTypeFilter, PaymentStatusFilter, PendingPaymentFilter,)
+    fields = ('payment_type', 'payment_object_link',)
+    readonly_fields = ('payment_type', 'payment_object_link',)
+    search_fields = \
+        tuple([prefix_search_field('adhesion', f) for f in AdhesionAdmin.search_fields]) \
+        + tuple([prefix_search_field('service', f) for f in ServiceAdmin.search_fields])
+
+    def get_queryset(self, request):
+        qs = super().get_queryset(request)
+        qs = qs.prefetch_related('updates')
+        qs = qs.prefetch_related(
+                models.Prefetch(
+                    'updates',
+                    queryset=PaymentUpdate.objects.filter(validated=False),
+                    to_attr='pending_updates'
+                )
+        )
+        return qs
+
+    def get_pending(self, obj):
+        return len(obj.pending_updates)
+    get_pending.short_description = 'Opérations en attente'
+
+    def payment_object_link(self, obj):
+        obj = obj.payment_object()
+        return format_html(u'<a href="{}">{}</a>', obj.get_absolute_url(), obj)
+    payment_object_link.short_description = 'Objet'
+
+    def get_actions(self, request):
+        actions = super().get_actions(request)
+        if 'delete_selected' in actions:
+            del actions['delete_selected']
+        return actions
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        return False
+
+
+admin.site.register(RecurringPayment, RecurringPaymentAdmin)

+ 3 - 0
banking/apps.py

@@ -4,3 +4,6 @@ from django.apps import AppConfig
 class BankingConfig(AppConfig):
 class BankingConfig(AppConfig):
     name = 'banking'
     name = 'banking'
     verbose_name = 'Comptabilité'
     verbose_name = 'Comptabilité'
+
+    def ready(self):
+        import banking.checks  # noqa

+ 10 - 0
banking/checks.py

@@ -0,0 +1,10 @@
+from django.core.checks import register, Error
+from django.conf import settings
+
+
+@register()
+def check_settings(app_configs, **kwargs):
+    errors = []
+    if not hasattr(settings, 'PAYMENTS_EMAILS'):
+        errors.append(Error('Missing settings variable PAYMENTS_EMAILS.'))
+    return errors

+ 50 - 0
banking/migrations/0006_auto_20170705_1825.py

@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.2 on 2017-07-05 19:25
+from __future__ import unicode_literals
+
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('banking', '0005_auto_20170211_0130'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='payment',
+            name='reason_type',
+        ),
+        migrations.DeleteModel(
+            name='Payment',
+        ),
+        migrations.CreateModel(
+            name='RecurringPayment',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+            ],
+            options={
+                'verbose_name': 'paiement récurrent',
+                'verbose_name_plural': 'paiements récurrents',
+            },
+        ),
+        migrations.CreateModel(
+            name='PaymentUpdate',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('amount', models.DecimalField(decimal_places=2, max_digits=9, verbose_name='Montant')),
+                ('period', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(12)], verbose_name='Période (mois)')),
+                ('payment_method', models.IntegerField(choices=[(2, 'Prélèvement'), (3, 'Virement'), (1, 'Gratuit'), (0, 'Arrêt')], default=2, verbose_name='Méthode de paiement')),
+                ('start', models.DateTimeField(verbose_name='Date')),
+                ('validated', models.BooleanField(default=False, verbose_name='Saisie bancaire effectuée')),
+                ('payment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='updates', to='banking.RecurringPayment')),
+            ],
+            options={
+                'verbose_name': 'paiement',
+                'ordering': ('-start',),
+            },
+        ),
+    ]

+ 22 - 0
banking/migrations/0007_paymentupdate_created.py

@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.2 on 2017-07-06 18:56
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('banking', '0006_auto_20170705_1825'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='paymentupdate',
+            name='created',
+            field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
+            preserve_default=False,
+        ),
+    ]

+ 67 - 96
banking/models.py

@@ -1,70 +1,76 @@
 from django.db import models
 from django.db import models
-from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.contenttypes.models import ContentType
-from django.core.validators import MaxValueValidator
-from django.core.urlresolvers import reverse
+from django.core.validators import MinValueValidator, MaxValueValidator
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
-from django.urls import reverse
-from django.core.exceptions import PermissionDenied
-from django.utils import timezone
+from django.core.urlresolvers import reverse
+
+
+class RecurringPayment(models.Model):
+    def get_last_validated_update(self):
+        for update in self.updates.all():
+            if update.validated:
+                return update
+    get_last_validated_update.short_description = 'Paiement'
 
 
-from djadhere.utils import is_overlapping, get_active_filter
+    def payment_type(self):
+        if hasattr(self, 'adhesion'):
+            return 'Adhésion'
+        if hasattr(self, 'service'):
+            return 'Service'
+    payment_type.short_description = 'Type'
+
+    def payment_object(self):
+        return self.adhesion if hasattr(self, 'adhesion') else self.service
+
+    def get_absolute_url(self):
+        return reverse('admin:%s_%s_change' % (self._meta.app_label, self._meta.model_name), args=(self.pk,))
 
 
+    def get_status(self):
+        payment = self.get_last_validated_update()
+        return payment is not None and payment.payment_method != PaymentUpdate.STOP
+    get_status.short_description = 'Actif'
+    get_status.boolean = True
 
 
-class Payment(models.Model):
-    TRANSFERT = 0
-    WITHDRAWAL = 1
+    class Meta:
+        verbose_name = 'paiement récurrent'
+        verbose_name_plural = 'paiements récurrents'
+
+    def __str__(self):
+        payment = self.get_last_validated_update()
+        return str(payment) if payment else "pas de paiement"
+
+
+class PaymentUpdate(models.Model):
+    STOP = 0
+    FREE = 1
+    DEBIT = 2
+    TRANSFER = 3
     PAYMENT_CHOICES = (
     PAYMENT_CHOICES = (
-        (TRANSFERT, 'Virement'),
-        (WITHDRAWAL, 'Prélèvement'),
+        (DEBIT, 'Prélèvement'),
+        (TRANSFER, 'Virement'),
+        (FREE, 'Gratuit'),
+        (STOP, 'Arrêt'),
     )
     )
-    ADHESION = 0
-    SERVICE = 1
-    limit = models.Q(app_label='adhesions', model='adhesion') \
-        | models.Q(app_label='services', model='service')
-    reason_type = models.ForeignKey(ContentType, on_delete=models.CASCADE,
-                                    limit_choices_to=limit)
-    reason_id = models.PositiveIntegerField()
-    reason = GenericForeignKey('reason_type', 'reason_id')
+    created = models.DateTimeField(auto_now_add=True)
+    payment = models.ForeignKey(RecurringPayment, related_name='updates')
     amount = models.DecimalField(max_digits=9, decimal_places=2, verbose_name='Montant')
     amount = models.DecimalField(max_digits=9, decimal_places=2, verbose_name='Montant')
-    period = models.PositiveIntegerField(validators=[MaxValueValidator(12)],
-                                         verbose_name='Période (mois) (0 si non récurrent)')
-    payment_method = models.IntegerField(choices=PAYMENT_CHOICES,
+    period = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(12)],
+                                         verbose_name='Période (mois)')
+    payment_method = models.IntegerField(choices=PAYMENT_CHOICES, default=DEBIT,
                                          verbose_name='Méthode de paiement')
                                          verbose_name='Méthode de paiement')
-    start = models.DateField(verbose_name='Date de paiement ou de début de paiement')
-    end = models.DateField(null=True, blank=True, verbose_name='Date de fin de paiement (si récurrent)')
-    validated = models.BooleanField(default=False, verbose_name='Paiement validé')
+    start = models.DateTimeField(verbose_name='Date')
+    validated = models.BooleanField(default=False, verbose_name='Saisie bancaire effectuée')
 
 
     class Meta:
     class Meta:
         verbose_name = 'paiement'
         verbose_name = 'paiement'
-        permissions = (
-            ('validate_payment', 'Peut valider les paiements'),
-        )
-
-    @property
-    def active(self):
-        # Contrairement aux allocations d’IP, un paiement est considéré actif même s’il commence dans le futur
-        # Seul les paiements récurrents peuvent être actifs
-        if self.period == 0:
-            return None
-        today = timezone.localtime(timezone.now()).date()
-        return self.end is None or (self.start <= today and self.end > today)
+        ordering = ('-start',)
 
 
-    @property
-    def type(self):
-        if self.reason_type.app_label == 'adhesions' \
-                and self.reason_type.model == 'adhesion':
-            return Payment.ADHESION
-        if self.reason_type.app_label == 'services' \
-                and self.reason_type.model == 'service':
-            return Payment.SERVICE
-
-    def type_verbose(self):
-        if self.type == Payment.ADHESION:
-            return 'Cotisation'
-        if self.type == Payment.SERVICE:
-            return 'Service'
-    type_verbose.short_description = 'Type'
+    def clean(self):
+        super().clean()
+        errors = {}
+        if self.payment_method == PaymentUpdate.STOP and self.amount:
+            errors.update({'amount': "Pour l’arrêt d’un paiement, le montant doit être nul."})
+        if errors:
+            raise ValidationError(errors)
 
 
     def period_verbose(self):
     def period_verbose(self):
         if self.period == 0:
         if self.period == 0:
@@ -80,46 +86,9 @@ class Payment(models.Model):
         else:
         else:
             return '%d mois' % self.period
             return '%d mois' % self.period
 
 
-    # FIXME
-    @property
-    def adherent(self):
-        return self.get_adhesion().adherent
-
-    def get_adhesion(self):
-        if self.reason_type.app_label == 'adhesions' \
-                and self.reason_type.model == 'adhesion':
-            return self.reason
-        if self.reason_type.app_label == 'services' \
-                and self.reason_type.model == 'service':
-            return self.reason.adhesion
-    get_adhesion.short_description = 'Adhésion'
-
-    # Penser à appele la méthode save !
-    def stop(self):
-        if not self.active:
-            raise PermissionDenied
-        today = timezone.localtime(timezone.now()).date()
-        self.end = max(self.start, today)
-
-    def clean(self):
-        super().clean()
-        # S’il s’agit d’un paiement non récurrent, le champ end doit rester blanc
-        if self.period == 0 and self.end:
-            raise ValidationError({'end': "Un paiement non récurrent ne doit pas avoir de date de fin."})
-        # Vérification de la cohérence des champs start et end
-        if self.end and self.start > self.end:
-            raise ValidationError("La date de début de paiement doit être antérieur à la date de fin de paiement.")
-        # Vérification de l’absence de chevauchement avec une période existante
-        # FIXME ne fonctionne pas vraiment via les inlines…
-        if self.reason:
-            payments = Payment.objects.filter(reason_type=self.reason_type, reason_id=self.reason_id)
-            if is_overlapping(self, payments):
-                raise ValidationError("Les périodes de paiement ne doivent pas se chevaucher.")
-
-    def get_absolute_url(self):
-        return reverse('payment-detail', kwargs={'pk': self.pk})
-
     def __str__(self):
     def __str__(self):
+        if self.payment_method == self.STOP:
+            return 'paiement arrêté'
         s = str(self.amount) + '€'
         s = str(self.amount) + '€'
         if self.period:
         if self.period:
             if self.period == 1:
             if self.period == 1:
@@ -128,8 +97,10 @@ class Payment(models.Model):
                 s += '/an'
                 s += '/an'
             else:
             else:
                 s += '/%d mois' % self.period
                 s += '/%d mois' % self.period
-        if self.payment_method == self.TRANSFERT:
-            s += ' (virement)'
-        elif self.payment_method == self.WITHDRAWAL:
+        if self.payment_method == self.DEBIT:
             s += ' (prélèvement)'
             s += ' (prélèvement)'
+        elif self.payment_method == self.TRANSFER:
+            s += ' (virement)'
+        elif self.payment_method == self.CASH:
+            s += ' (liquide)'
         return s
         return s

+ 0 - 11
banking/urls.py

@@ -1,11 +0,0 @@
-from django.conf.urls import url, include
-
-from . import views
-
-
-urlpatterns = [
-    # Admin views
-    url(r'^admin/paiements/$', views.PaymentList.as_view(), name='payment-list'),
-    url(r'^admin/paiements/(?P<pk>[0-9]+)/$', views.PaymentDetail.as_view(), name='payment-detail'),
-    url(r'^admin/paiements/(?P<pk>[0-9]+)/stop/$', views.PaymentStop.as_view(), name='payment-stop'),
-]

+ 32 - 0
banking/utils.py

@@ -0,0 +1,32 @@
+from django.core.urlresolvers import reverse
+from django.conf import settings
+
+from djadhere.utils import send_notification
+from .models import PaymentUpdate
+
+
+def notify_payment_update(request, update, old_update=None):
+    benevole = '%s <%s>' % (request.user.username, request.user.email)
+    message = 'Bénévole : ' + benevole
+    message += '\n\nPaiement :\n\n'
+
+    try:
+        last_validated_update = PaymentUpdate.objects.latest('start')
+    except PaymentUpdate.DoesNotExist:
+        last_validated_update = None
+
+    if old_update:
+        message += '\t- %s\n' % old_update
+        message += '\t+ %s\n' % update
+        subject = 'Mise à jour d’une demande de saisie bancaire'
+    else:
+        message += '\t%s\n' % update
+        subject = 'Nouvelle demande de saisie bancaire'
+    subject += ' %s %d' % (update.payment.payment_type(), update.payment.payment_object().pk)
+
+    url = 'https' if request.is_secure() else 'http'
+    url += '://' + request.get_host()
+    url += reverse('admin:banking_recurringpayment_change', args=(update.payment.pk,))
+    message += '\nVoir : ' + url
+
+    send_notification(subject, message, settings.PAYMENTS_EMAILS, cc=[benevole])

+ 0 - 32
banking/views.py

@@ -1,32 +0,0 @@
-from django.shortcuts import render
-from django.views.generic import ListView, DetailView, RedirectView
-from django.views.generic.detail import SingleObjectMixin
-from django.contrib.auth.mixins import PermissionRequiredMixin
-
-from .models import Payment
-
-
-class PaymentMixin(PermissionRequiredMixin):
-    model = Payment
-    permission_required = 'banking.change_payment'
-
-
-class PaymentList(PaymentMixin, ListView):
-    paginate_by = 50
-
-
-class PaymentDetail(PaymentMixin, DetailView):
-    pass
-
-
-class PaymentStop(PaymentMixin, SingleObjectMixin, RedirectView):
-    #http_method_names = ['post']
-
-    def get_object(self, queryset=None):
-        obj = super().get_object(queryset)
-        obj.stop()
-        obj.save()
-        return obj
-
-    def get_redirect_url(self, *args, **kwargs):
-        return self.get_object().get_absolute_url()

+ 0 - 1
djadhere/urls.py

@@ -22,7 +22,6 @@ urlpatterns = [
     url(r'^accounts/', include('accounts.urls')),
     url(r'^accounts/', include('accounts.urls')),
     url(r'^', include('services.urls')),
     url(r'^', include('services.urls')),
     url(r'^', include('adhesions.urls')),
     url(r'^', include('adhesions.urls')),
-    url(r'^', include('banking.urls')),
     url(r'^admin/', admin.site.urls),
     url(r'^admin/', admin.site.urls),
 ]
 ]
 
 

+ 10 - 0
djadhere/utils.py

@@ -1,7 +1,9 @@
+from django.core.mail.message import EmailMultiAlternatives
 from django.db.models import Q
 from django.db.models import Q
 from django.utils import timezone
 from django.utils import timezone
 from django.forms import widgets
 from django.forms import widgets
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
+from django.conf import settings
 
 
 
 
 # Ce widget permet d’afficher un champ de formulaire sous forme de texte
 # Ce widget permet d’afficher un champ de formulaire sous forme de texte
@@ -54,3 +56,11 @@ def is_overlapping(instance, queryset):
             assert(not instance.end and not existing.end)
             assert(not instance.end and not existing.end)
             # Aucune des périodes n’est terminées
             # Aucune des périodes n’est terminées
             return True
             return True
+
+
+def send_notification(subject, message, recipients, **kwargs):
+    mail = EmailMultiAlternatives(
+        '%s%s' % (settings.EMAIL_SUBJECT_PREFIX, subject), message,
+        settings.SERVER_EMAIL, recipients, **kwargs,
+    )
+    mail.send()

+ 6 - 0
services/admin.py

@@ -167,6 +167,8 @@ class ServiceAdmin(admin.ModelAdmin):
     )
     )
     inlines = (ServiceAllocationInline,)
     inlines = (ServiceAllocationInline,)
     search_fields = ('=id', 'service_type__name', 'label', 'notes',)
     search_fields = ('=id', 'service_type__name', 'label', 'notes',)
+    fields = ('adhesion', 'service_type', 'label', 'notes', 'active', 'get_contribution_link',)
+    readonly_fields = ('get_contribution_link',)
     raw_id_fields = ('adhesion',)
     raw_id_fields = ('adhesion',)
 
 
     get_adhesion_link = lambda self, service: service.adhesion.get_adhesion_link()
     get_adhesion_link = lambda self, service: service.adhesion.get_adhesion_link()
@@ -175,6 +177,10 @@ class ServiceAdmin(admin.ModelAdmin):
     get_adherent_link = lambda self, service: service.adhesion.get_adherent_link()
     get_adherent_link = lambda self, service: service.adhesion.get_adherent_link()
     get_adherent_link.short_description = Adhesion.get_adherent_link.short_description
     get_adherent_link.short_description = Adhesion.get_adherent_link.short_description
 
 
+    def get_contribution_link(self, obj):
+        return format_html(u'<a href="{}">{}</a>', obj.contribution.get_absolute_url(), obj.contribution)
+    get_contribution_link.short_description = 'Contribution financière'
+
     def get_actions(self, request):
     def get_actions(self, request):
         actions = super().get_actions(request)
         actions = super().get_actions(request)
         if 'delete_selected' in actions:
         if 'delete_selected' in actions:

+ 1 - 0
services/apps.py

@@ -7,3 +7,4 @@ class ServicesConfig(AppConfig):
 
 
     def ready(self):
     def ready(self):
         import services.signals  # noqa
         import services.signals  # noqa
+        import services.checks  # noqa

+ 10 - 0
services/checks.py

@@ -0,0 +1,10 @@
+from django.core.checks import register, Error
+from django.conf import settings
+
+
+@register()
+def check_settings(app_configs, **kwargs):
+    errors = []
+    if not hasattr(settings, 'ALLOCATIONS_EMAILS'):
+        errors.append(Error('Missing settings variable ALLOCATIONS_EMAILS.'))
+    return errors

+ 38 - 0
services/migrations/0036_service_contribution.py

@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.2 on 2017-07-06 18:59
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def create_default_contribution(apps, schema_editor):
+    db_alias = schema_editor.connection.alias
+    Service = apps.get_model("services", "Service")
+    RecurringPayment = apps.get_model("banking", "RecurringPayment")
+    for service in Service.objects.using(db_alias).all():
+        if not service.contribution:
+            service.contribution = RecurringPayment.objects.using(db_alias).create()
+            service.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('banking', '0006_auto_20170705_1825'),
+        ('services', '0035_antenna_orientation'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='service',
+            name='contribution',
+            field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to='banking.RecurringPayment'),
+        ),
+        migrations.RunPython(create_default_contribution),
+        migrations.AlterField(
+            model_name='service',
+            name='contribution',
+            field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='banking.RecurringPayment'),
+        ),
+    ]

+ 10 - 12
services/models.py

@@ -15,7 +15,7 @@ from ipaddress import ip_network
 
 
 from djadhere.utils import get_active_filter, is_overlapping
 from djadhere.utils import get_active_filter, is_overlapping
 from adhesions.models import Adhesion
 from adhesions.models import Adhesion
-from banking.models import Payment
+from banking.models import RecurringPayment
 
 
 
 
 def ipprefix_validator(value):
 def ipprefix_validator(value):
@@ -122,18 +122,13 @@ class Service(models.Model):
     notes = models.TextField(blank=True, default='')
     notes = models.TextField(blank=True, default='')
     active = models.BooleanField(default=True, verbose_name='actif')
     active = models.BooleanField(default=True, verbose_name='actif')
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
-    contributions = GenericRelation(Payment,
-                                   content_type_field='reason_type',
-                                   object_id_field='reason_id',
-                                   related_query_name='service')
 
 
-    @property
-    def contribution(self):
-        try:
-            return self.contributions.exclude(period=0).get(get_active_filter())
-        except Payment.DoesNotExist:
-            return None
-        # MultipleObjectsReturned non catché volontairement, cf remarque adhesions.Adhesion.contribution
+    contribution = models.OneToOneField(RecurringPayment)
+
+    def save(self, *args, **kwargs):
+        if not self.contribution:
+            self.contribution = RecurringPayment.objects.create()
+        super().save(*args, **kwargs)
 
 
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
@@ -141,6 +136,9 @@ class Service(models.Model):
         if self.label != '' and Service.objects.exclude(pk=self.pk).filter(service_type=self.service_type, label=self.label):
         if self.label != '' and Service.objects.exclude(pk=self.pk).filter(service_type=self.service_type, label=self.label):
             raise ValidationError("Un service du même type existe déjà avec ce label.")
             raise ValidationError("Un service du même type existe déjà avec ce label.")
 
 
+    def get_absolute_url(self):
+        return reverse('admin:%s_%s_change' % (self._meta.app_label, self._meta.model_name), args=(self.pk,))
+
     def __str__(self):
     def __str__(self):
         s = '#%d %s' % (self.pk, self.service_type)
         s = '#%d %s' % (self.pk, self.service_type)
         if self.label:
         if self.label:

+ 2 - 16
services/utils.py

@@ -1,21 +1,7 @@
-from django.core.mail.message import EmailMultiAlternatives
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 from django.conf import settings
 from django.conf import settings
 
 
-
-def mail_managers(subject, message, fail_silently=False, connection=None,
-                  html_message=None, **kwargs):
-    """Send a message to the managers, as defined by the MANAGERS setting."""
-    if not settings.MANAGERS:
-        return
-    mail = EmailMultiAlternatives(
-        '%s%s' % (settings.EMAIL_SUBJECT_PREFIX, subject), message,
-        settings.SERVER_EMAIL, [a[1] for a in settings.MANAGERS],
-        connection=connection, **kwargs
-    )
-    if html_message:
-        mail.attach_alternative(html_message, 'text/html')
-    mail.send(fail_silently=fail_silently)
+from djadhere.utils import send_notification
 
 
 
 
 def notify_allocation(request, new_alloc, old_alloc=None):
 def notify_allocation(request, new_alloc, old_alloc=None):
@@ -54,4 +40,4 @@ def notify_allocation(request, new_alloc, old_alloc=None):
 
 
     if sujet:
     if sujet:
         sujet += ' ADT%d' % new_alloc.service.adhesion.pk
         sujet += ' ADT%d' % new_alloc.service.adhesion.pk
-        mail_managers(sujet, message, cc=[benevole])
+        send_notification(sujet, message, settings.ALLOCATIONS_EMAILS, cc=[benevole])