Browse Source

gestion des paiements

Élie Bouttier 7 years ago
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.admin import UserAdmin as AuthUserAdmin
 from django.http import HttpResponseRedirect
+from django.utils.html import format_html
+from django.core.urlresolvers import reverse
 
 from .forms import UserCreationForm
 from .models import User, Corporation, Adhesion
 from accounts.models import Profile
 from services.models import Service
-from banking.admin import PaymentInline
 
 
 ### Inlines
@@ -205,18 +206,22 @@ class AdhesionAdmin(AdtSearchMixin, admin.ModelAdmin):
     list_display = ('get_id', 'type', 'get_adherent_link',)
     list_filter = (AdherentTypeFilter, 'active',)
     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',) \
                     + 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])
-    inlines = (ServiceInline,)#PaymentInline,)
+    inlines = (ServiceInline,)
 
     def get_id(self, obj):
         return 'ADT%d' % obj.id
     get_id.short_description = 'Numéro d’adhérent'
     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):
         actions = super().get_actions(request)
         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 djadhere.utils import get_active_filter
-from banking.models import Payment
+from banking.models import RecurringPayment
 
 
 class User(AuthUser):
@@ -58,10 +58,6 @@ class Adhesion(models.Model):
     limit = models.Q(app_label='auth', model='user') \
           | models.Q(app_label='adhesions', model='corporation')
     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)
     notes = models.TextField(blank=True, default='')
     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)
     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:
         verbose_name = 'adhésion'
         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):
         return self.user is not None
 

+ 148 - 150
banking/admin.py

@@ -1,172 +1,170 @@
 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.forms import BaseInlineFormSet
+from django.utils.html import format_html
 
 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
 
-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):
-    title = 'type de paiement'
+    title = 'type'
     parameter_name = 'type'
 
     def lookups(self, request, model_admin):
-        choices = [
-            ('membership', 'Cotisation'),
+        return (
+            ('adhesion', 'Adhésion'),
             ('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):
-        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':
-            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
 
-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):
     name = 'banking'
     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.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.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 = (
-        (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')
-    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')
-    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:
         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):
         if self.period == 0:
@@ -80,46 +86,9 @@ class Payment(models.Model):
         else:
             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):
+        if self.payment_method == self.STOP:
+            return 'paiement arrêté'
         s = str(self.amount) + '€'
         if self.period:
             if self.period == 1:
@@ -128,8 +97,10 @@ class Payment(models.Model):
                 s += '/an'
             else:
                 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)'
+        elif self.payment_method == self.TRANSFER:
+            s += ' (virement)'
+        elif self.payment_method == self.CASH:
+            s += ' (liquide)'
         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'^', include('services.urls')),
     url(r'^', include('adhesions.urls')),
-    url(r'^', include('banking.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.utils import timezone
 from django.forms import widgets
 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
@@ -54,3 +56,11 @@ def is_overlapping(instance, queryset):
             assert(not instance.end and not existing.end)
             # Aucune des périodes n’est terminées
             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,)
     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',)
 
     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.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):
         actions = super().get_actions(request)
         if 'delete_selected' in actions:

+ 1 - 0
services/apps.py

@@ -7,3 +7,4 @@ class ServicesConfig(AppConfig):
 
     def ready(self):
         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 adhesions.models import Adhesion
-from banking.models import Payment
+from banking.models import RecurringPayment
 
 
 def ipprefix_validator(value):
@@ -122,18 +122,13 @@ class Service(models.Model):
     notes = models.TextField(blank=True, default='')
     active = models.BooleanField(default=True, verbose_name='actif')
     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):
         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):
             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):
         s = '#%d %s' % (self.pk, self.service_type)
         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.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):
@@ -54,4 +40,4 @@ def notify_allocation(request, new_alloc, old_alloc=None):
 
     if sujet:
         sujet += ' ADT%d' % new_alloc.service.adhesion.pk
-        mail_managers(sujet, message, cc=[benevole])
+        send_notification(sujet, message, settings.ALLOCATIONS_EMAILS, cc=[benevole])