Browse Source

Merge branch 'enh-donations' into arndev

ljf 7 years ago
parent
commit
2be2108c59

+ 27 - 4
coin/billing/admin.py

@@ -8,8 +8,11 @@ from django.conf.urls import url
 from django.contrib.admin.utils import flatten_fieldsets
 from django.contrib.admin.utils import flatten_fieldsets
 
 
 from coin.filtering_queryset import LimitedAdminInlineMixin
 from coin.filtering_queryset import LimitedAdminInlineMixin
-from coin.billing.models import Invoice, InvoiceDetail, Payment, PaymentAllocation
+from coin.billing.models import Invoice, InvoiceDetail, Payment, \
+    PaymentAllocation, MembershipFee, Donation
 from coin.billing.utils import get_invoice_from_id_or_number
 from coin.billing.utils import get_invoice_from_id_or_number
+from coin.billing.membershipfee_filter import MembershipFeeFilter
+from coin.members.admin import MemberAdmin
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 import autocomplete_light
 import autocomplete_light
 
 
@@ -173,7 +176,7 @@ class InvoiceAdmin(admin.ModelAdmin):
         # TODO : Add better perm here
         # TODO : Add better perm here
         if request.user.is_superuser:
         if request.user.is_superuser:
             invoice = get_invoice_from_id_or_number(id)
             invoice = get_invoice_from_id_or_number(id)
-            if invoice.amount() == 0:
+            if invoice.amount == 0:
                 messages.error(request, 'Une facture validée ne peut pas avoir'
                 messages.error(request, 'Une facture validée ne peut pas avoir'
                                         ' un total de 0€.')
                                         ' un total de 0€.')
             else:
             else:
@@ -191,8 +194,8 @@ class InvoiceAdmin(admin.ModelAdmin):
 class PaymentAllocationInlineReadOnly(admin.TabularInline):
 class PaymentAllocationInlineReadOnly(admin.TabularInline):
     model = PaymentAllocation
     model = PaymentAllocation
     extra = 0
     extra = 0
-    fields = ("invoice", "amount")
-    readonly_fields = ("invoice", "amount")
+    fields = ("bill", "amount")
+    readonly_fields = ("bill", "amount")
     verbose_name = None
     verbose_name = None
     verbose_name_plural = "Alloué à"
     verbose_name_plural = "Alloué à"
 
 
@@ -226,5 +229,25 @@ class PaymentAdmin(admin.ModelAdmin):
     def get_inline_instances(self, request, obj=None):
     def get_inline_instances(self, request, obj=None):
         return [PaymentAllocationInlineReadOnly(self.model, self.admin_site)]
         return [PaymentAllocationInlineReadOnly(self.model, self.admin_site)]
 
 
+
+class MembershipFeeAdmin(admin.ModelAdmin):
+    list_display = ('member', 'end_date', '_amount')
+    form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
+
+
+class DonationAdmin(admin.ModelAdmin):
+    list_display = ('member', 'date', '_amount')
+    form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
+
+class MembershipFeeInline(admin.TabularInline):
+    model = MembershipFee
+    extra = 0
+    fields = ('start_date', 'end_date', '_amount')
+
+MemberAdmin.list_filter += ('status', MembershipFeeFilter)
+MemberAdmin.inlines += [MembershipFeeInline]
+
 admin.site.register(Invoice, InvoiceAdmin)
 admin.site.register(Invoice, InvoiceAdmin)
 admin.site.register(Payment, PaymentAdmin)
 admin.site.register(Payment, PaymentAdmin)
+admin.site.register(MembershipFee, MembershipFeeAdmin)
+admin.site.register(Donation, DonationAdmin)

coin/members/membershipfee_filter.py → coin/billing/membershipfee_filter.py


+ 19 - 0
coin/billing/migrations/0011_auto_20180414_2250.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0010_new_billing_system_data'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='payment',
+            name='amount',
+            field=models.DecimalField(null=True, verbose_name='montant', max_digits=6, decimal_places=2),
+        ),
+    ]

+ 96 - 0
coin/billing/migrations/0012_auto_20180415_1502.py

@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import datetime
+import coin.billing.models
+import django.db.models.deletion
+import django.core.files.storage
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('billing', '0011_auto_20180414_2250'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Bill',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('status2', models.CharField(default='open', max_length=50, verbose_name='statut', choices=[('open', '\xc0 payer'), ('closed', 'R\xe9gl\xe9e'), ('trouble', 'Litige')])),
+                ('date2', models.DateField(default=datetime.date.today, help_text='Cette date sera d\xe9finie \xe0 la date de validation dans le document final', null=True, verbose_name='date')),
+                ('pdf2', models.FileField(storage=django.core.files.storage.FileSystemStorage(location='/vagrant/apps/extra/coin2/smedia/'), upload_to=coin.billing.models.bill_pdf_filename, null=True, verbose_name='PDF', blank=True)),
+            ],
+            options={
+                'verbose_name': 'note',
+            },
+        ),
+        migrations.RemoveField(
+            model_name='invoice',
+            name='id',
+        ),
+        migrations.AlterField(
+            model_name='invoice',
+            name='date',
+            field=models.DateField(default=datetime.date.today, help_text='Cette date sera d\xe9finie \xe0 la date de validation dans le document final', null=True, verbose_name='date'),
+        ),
+        migrations.AlterField(
+            model_name='invoice',
+            name='pdf',
+            field=models.FileField(storage=django.core.files.storage.FileSystemStorage(location='/vagrant/apps/extra/coin2/smedia/'), upload_to=coin.billing.models.bill_pdf_filename, null=True, verbose_name='PDF', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='payment',
+            name='invoice',
+            field=models.ForeignKey(related_name='payments_old', verbose_name='facture associ\xe9e', blank=True, to='billing.Invoice', null=True),
+        ),
+        migrations.AlterField(
+            model_name='paymentallocation',
+            name='invoice',
+            field=models.ForeignKey(related_name='allocations', verbose_name='facture associ\xe9e', to='billing.Bill'),
+        ),
+        migrations.CreateModel(
+            name='Donation',
+            fields=[
+                ('bill_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='billing.Bill')),
+                ('_amount', models.DecimalField(verbose_name='Montant', max_digits=8, decimal_places=2)),
+            ],
+            options={
+                'verbose_name': 'don',
+            },
+            bases=('billing.bill',),
+        ),
+        migrations.CreateModel(
+            name='TempMembershipFee',
+            fields=[
+                ('bill_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='billing.Bill')),
+                ('_amount', models.DecimalField(default=None, help_text='en \u20ac', verbose_name='montant', max_digits=5, decimal_places=2)),
+                ('start_date', models.DateField(verbose_name='date de d\xe9but de cotisation')),
+                ('end_date', models.DateField(help_text='par d\xe9faut, la cotisation dure un an', verbose_name='date de fin de cotisation', blank=True)),
+            ],
+            options={
+                'verbose_name': 'cotisation',
+            },
+            bases=('billing.bill',),
+        ),
+        migrations.AddField(
+            model_name='bill',
+            name='member2',
+            field=models.ForeignKey(related_name='bills', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to=settings.AUTH_USER_MODEL, null=True, verbose_name='membre'),
+        ),
+        migrations.AddField(
+            model_name='invoice',
+            name='bill_ptr',
+            field=models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, default=1, serialize=False, to='billing.Bill'),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='payment',
+            name='bill',
+            field=models.ForeignKey(related_name='payments', verbose_name='facture associ\xe9e', blank=True, to='billing.Bill', null=True),
+        ),
+    ]

+ 63 - 0
coin/billing/migrations/0013_auto_20180415_0413.py

@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+def forwards(apps, schema_editor):
+
+    Payment = apps.get_model('billing', 'Payment')
+    Invoice = apps.get_model('billing', 'Invoice')
+    MembershipFee = apps.get_model('members', 'MembershipFee')
+    TempMembershipFee = apps.get_model('billing', 'TempMembershipFee')
+    PaymentAllocation = apps.get_model('billing', 'PaymentAllocation')
+
+    # Update payment
+    for payment in Payment.objects.all():
+        payment.bill = payment.invoice
+        payment.save()
+
+    # Update invoice data
+    for invoice in Invoice.objects.all():
+        invoice.member2 = invoice.member
+        invoice.status2 = invoice.status
+        invoice.date2 = invoice.date
+        invoice.pdf2 = invoice.pdf
+        invoice.save()
+
+    # Update balance for all members
+    for fee in MembershipFee.objects.all():
+
+        temp_fee = TempMembershipFee()
+        temp_fee._amount = fee.amount
+        temp_fee.start_date = fee.start_date
+        temp_fee.end_date = fee.end_date
+        temp_fee.status2 = 'closed'
+        temp_fee.date2 = temp_fee.start_date
+        temp_fee.member2 = fee.member
+        temp_fee.save()
+
+        payment = Payment()
+        payment.member = fee.member
+        payment.payment_mean = fee.payment_method
+        payment.amount = fee.amount
+        payment.date = fee.payment_date
+        payment.label = fee.reference
+        payment.bill = temp_fee
+        payment.save()
+
+        allocation = PaymentAllocation()
+        allocation.invoice = temp_fee
+        allocation.payment = payment
+        allocation.amount = fee.amount
+        allocation.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0012_auto_20180415_1502'),
+    ]
+
+    operations = [
+        migrations.RunPython(forwards),
+    ]

+ 54 - 0
coin/billing/migrations/0014_auto_20180415_1814.py

@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0013_auto_20180415_0413'),
+    ]
+
+    operations = [
+        migrations.RenameModel(
+            old_name='TempMembershipFee',
+            new_name='MembershipFee',
+        ),
+        migrations.RenameField(
+            model_name='bill',
+            old_name='date2',
+            new_name='date',
+        ),
+        migrations.RenameField(
+            model_name='bill',
+            old_name='member2',
+            new_name='member',
+        ),
+        migrations.RenameField(
+            model_name='bill',
+            old_name='pdf2',
+            new_name='pdf',
+        ),
+        migrations.RenameField(
+            model_name='bill',
+            old_name='status2',
+            new_name='status',
+        ),
+        migrations.RemoveField(
+            model_name='invoice',
+            name='date',
+        ),
+        migrations.RemoveField(
+            model_name='invoice',
+            name='member',
+        ),
+        migrations.RemoveField(
+            model_name='invoice',
+            name='pdf',
+        ),
+        migrations.RemoveField(
+            model_name='invoice',
+            name='status',
+        ),
+    ]

+ 18 - 0
coin/billing/migrations/0015_remove_payment_invoice.py

@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0014_auto_20180415_1814'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='payment',
+            name='invoice',
+        ),
+    ]

+ 19 - 0
coin/billing/migrations/0016_auto_20180415_2208.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0015_remove_payment_invoice'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='paymentallocation',
+            old_name='invoice',
+            new_name='bill',
+        ),
+    ]

+ 245 - 112
coin/billing/models.py

@@ -5,6 +5,7 @@ import datetime
 import logging
 import logging
 import uuid
 import uuid
 import re
 import re
+import abc
 from decimal import Decimal
 from decimal import Decimal
 from dateutil.relativedelta import relativedelta
 from dateutil.relativedelta import relativedelta
 
 
@@ -29,13 +30,116 @@ from coin.isp_database.models import ISPInfo
 accounting_log = logging.getLogger("coin.billing")
 accounting_log = logging.getLogger("coin.billing")
 
 
 
 
-def invoice_pdf_filename(instance, filename):
+def bill_pdf_filename(instance, filename):
     """Nom et chemin du fichier pdf à stocker pour les factures"""
     """Nom et chemin du fichier pdf à stocker pour les factures"""
     member_id = instance.member.id if instance.member else 0
     member_id = instance.member.id if instance.member else 0
     return 'invoices/%d_%s_%s.pdf' % (member_id,
     return 'invoices/%d_%s_%s.pdf' % (member_id,
                                       instance.number,
                                       instance.number,
                                       uuid.uuid4())
                                       uuid.uuid4())
 
 
+def invoice_pdf_filename(instance, filename):
+    return bill_pdf_filename(instance, filename)
+
+class Bill(models.Model):
+
+    BILL_STATUS_CHOICES = (
+        ('open', 'À payer'),
+        ('closed', 'Réglée'),
+        ('trouble', 'Litige')
+    )
+
+    status = models.CharField(max_length=50, choices=BILL_STATUS_CHOICES,
+                              default='open',
+                              verbose_name='statut')
+    date = models.DateField(
+        default=datetime.date.today, null=True, verbose_name='date',
+        help_text='Cette date sera définie à la date de validation dans le document final')
+    member = models.ForeignKey(Member, null=True, blank=True, default=None,
+                               related_name='bills',
+                               verbose_name='membre',
+                               on_delete=models.SET_NULL)
+    pdf = models.FileField(storage=private_files_storage,
+                           upload_to=bill_pdf_filename,
+                           null=True, blank=True,
+                           verbose_name='PDF')
+    @property
+    def amount(self):
+        """ Return bill amount """
+        return self.cast.amount
+    amount.fget.short_description = 'Montant'
+
+    def amount_paid(self):
+        """
+        Calcul le montant déjà payé à partir des allocations de paiements
+        """
+        return sum([a.amount for a in self.allocations.all()])
+    amount_paid.short_description = 'Montant payé'
+
+    def amount_remaining_to_pay(self):
+        """
+        Calcul le montant restant à payer
+        """
+        return self.amount - self.amount_paid()
+    amount_remaining_to_pay.short_description = 'Reste à payer'
+
+    def has_owner(self, username):
+        """
+        Check if passed username (ex gmajax) is owner of the invoice
+        """
+        return (self.member and self.member.username == username)
+
+    def generate_pdf(self):
+        """
+        Make and store a pdf file for the invoice
+        """
+        context = {"invoice": self}
+        context.update(branding(None))
+        pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
+        self.pdf.save('%s.pdf' % self.number, pdf_file)
+
+    def pdf_exists(self):
+        return (bool(self.pdf)
+                and private_files_storage.exists(self.pdf.name))
+
+#    def get_absolute_url(self):
+#        return reverse('billing:invoice', args=[self.number])
+
+    def __unicode__(self):
+        return '%s - %s - %i€' % (self.member, self.date, self.amount)
+
+    @property
+    def reference(self):
+        if hasattr(self, 'membershipfee'):
+            return 'Cotisation'
+        elif hasattr(self, 'donation'):
+            return 'Don'
+        elif hasattr(self, 'invoice'):
+            return self.invoice.number
+
+    def log_change(self, created):
+
+        if created:
+            accounting_log.info("Creating %s %s (Member: %s)."
+                                % (self.pk, self.member))
+        else:
+            accounting_log.info("Updating %s %s (Member: %s, Total amount: %s, Amount paid: %s)."
+                        % (self.pk, self.member, self.amount, self.amount_paid() ))
+
+    @property
+    def cast(bill):
+        if hasattr(bill, 'membershipfee'):
+            return bill.membershipfee
+        elif hasattr(bill, 'donation'):
+            return bill.donation
+        elif hasattr(bill, 'invoice'):
+            return bill.invoice
+    @staticmethod
+    def get_member_validated_bills(member):
+        related_fields = ['membershipfee', 'donation', 'invoice']
+        return [i.cast for i in member.bills.order_by("date") if i.cast.validated]
+
+    class Meta:
+        verbose_name = 'note'
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
 class InvoiceNumber:
 class InvoiceNumber:
@@ -117,13 +221,8 @@ class InvoiceQuerySet(models.QuerySet):
             InvoiceNumber.RE_INVOICE_NUMBER))
             InvoiceNumber.RE_INVOICE_NUMBER))
 
 
 
 
-class Invoice(models.Model):
+class Invoice(Bill):
 
 
-    INVOICES_STATUS_CHOICES = (
-        ('open', 'À payer'),
-        ('closed', 'Réglée'),
-        ('trouble', 'Litige')
-    )
 
 
     validated = models.BooleanField(default=False, verbose_name='validée',
     validated = models.BooleanField(default=False, verbose_name='validée',
                                     help_text='Once validated, a PDF is generated'
                                     help_text='Once validated, a PDF is generated'
@@ -131,24 +230,10 @@ class Invoice(models.Model):
     number = models.CharField(max_length=25,
     number = models.CharField(max_length=25,
                               unique=True,
                               unique=True,
                               verbose_name='numéro')
                               verbose_name='numéro')
-    status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
-                              default='open',
-                              verbose_name='statut')
-    date = models.DateField(
-        default=datetime.date.today, null=True, verbose_name='date',
-        help_text='Cette date sera définie à la date de validation dans la facture finale')
     date_due = models.DateField(
     date_due = models.DateField(
         null=True, blank=True,
         null=True, blank=True,
         verbose_name="date d'échéance de paiement",
         verbose_name="date d'échéance de paiement",
         help_text='Le délai de paiement sera fixé à {} jours à la validation si laissé vide'.format(settings.PAYMENT_DELAY))
         help_text='Le délai de paiement sera fixé à {} jours à la validation si laissé vide'.format(settings.PAYMENT_DELAY))
-    member = models.ForeignKey(Member, null=True, blank=True, default=None,
-                               related_name='invoices',
-                               verbose_name='membre',
-                               on_delete=models.SET_NULL)
-    pdf = models.FileField(storage=private_files_storage,
-                           upload_to=invoice_pdf_filename,
-                           null=True, blank=True,
-                           verbose_name='PDF')
 
 
     date_last_reminder_email = models.DateTimeField(null=True, blank=True,
     date_last_reminder_email = models.DateTimeField(null=True, blank=True,
                         verbose_name="Date du dernier email de relance envoyé")
                         verbose_name="Date du dernier email de relance envoyé")
@@ -161,6 +246,7 @@ class Invoice(models.Model):
             self.number = 'DRAFT-{}'.format(self.pk)
             self.number = 'DRAFT-{}'.format(self.pk)
             self.save()
             self.save()
 
 
+    @property
     def amount(self):
     def amount(self):
         """
         """
         Calcul le montant de la facture
         Calcul le montant de la facture
@@ -170,7 +256,7 @@ class Invoice(models.Model):
         for detail in self.details.all():
         for detail in self.details.all():
             total += detail.total()
             total += detail.total()
         return total.quantize(Decimal('0.01'))
         return total.quantize(Decimal('0.01'))
-    amount.short_description = 'Montant'
+    amount.fget.short_description = 'Montant'
 
 
     def amount_before_tax(self):
     def amount_before_tax(self):
         total = Decimal('0.0')
         total = Decimal('0.0')
@@ -179,34 +265,6 @@ class Invoice(models.Model):
         return total.quantize(Decimal('0.01'))
         return total.quantize(Decimal('0.01'))
     amount_before_tax.short_description = 'Montant HT'
     amount_before_tax.short_description = 'Montant HT'
 
 
-    def amount_paid(self):
-        """
-        Calcul le montant déjà payé à partir des allocations de paiements
-        """
-        return sum([a.amount for a in self.allocations.all()])
-    amount_paid.short_description = 'Montant payé'
-
-    def amount_remaining_to_pay(self):
-        """
-        Calcul le montant restant à payer
-        """
-        return self.amount() - self.amount_paid()
-    amount_remaining_to_pay.short_description = 'Reste à payer'
-
-    def has_owner(self, username):
-        """
-        Check if passed username (ex gmajax) is owner of the invoice
-        """
-        return (self.member and self.member.username == username)
-
-    def generate_pdf(self):
-        """
-        Make and store a pdf file for the invoice
-        """
-        context = {"invoice": self}
-        context.update(branding(None))
-        pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
-        self.pdf.save('%s.pdf' % self.number, pdf_file)
 
 
     @transaction.atomic
     @transaction.atomic
     def validate(self):
     def validate(self):
@@ -226,7 +284,7 @@ class Invoice(models.Model):
 
 
         accounting_log.info("Draft invoice %s validated as invoice %s. "
         accounting_log.info("Draft invoice %s validated as invoice %s. "
                             "(Total amount : %f ; Member : %s)"
                             "(Total amount : %f ; Member : %s)"
-                            % (old_number, self.number, self.amount(), self.member))
+                            % (old_number, self.number, self.amount, self.member))
         assert self.pdf_exists()
         assert self.pdf_exists()
         if self.member is not None:
         if self.member is not None:
             update_accounting_for_member(self.member)
             update_accounting_for_member(self.member)
@@ -237,11 +295,8 @@ class Invoice(models.Model):
                 and bool(self.pdf)
                 and bool(self.pdf)
                 and private_files_storage.exists(self.pdf.name))
                 and private_files_storage.exists(self.pdf.name))
 
 
-    def get_absolute_url(self):
-        return reverse('billing:invoice', args=[self.number])
-
     def __unicode__(self):
     def __unicode__(self):
-        return '#%s %0.2f€ %s' % (self.number, self.amount(), self.date_due)
+        return '#%s %0.2f€ %s' % (self.number, self.amount, self.date_due)
 
 
     def reminder_needed(self):
     def reminder_needed(self):
 
 
@@ -302,6 +357,18 @@ class Invoice(models.Model):
         self.save()
         self.save()
         return True
         return True
 
 
+    def log_change(self, created):
+
+        if created:
+            accounting_log.info("Creating draft invoice %s (Member: %s)."
+                                % ('DRAFT-{}'.format(self.pk), self.member))
+        else:
+            if not self.validated:
+                accounting_log.info("Updating draft invoice %s (Member: %s)."
+                        % (self.number, self.member))
+            else:
+                accounting_log.info("Updating invoice %s (Member: %s, Total amount: %s, Amount paid: %s)."
+                        % (self.number, self.member, self.amount, self.amount_paid() ))
     class Meta:
     class Meta:
         verbose_name = 'facture'
         verbose_name = 'facture'
 
 
@@ -349,6 +416,69 @@ class InvoiceDetail(models.Model):
         verbose_name = 'détail de facture'
         verbose_name = 'détail de facture'
 
 
 
 
+class Donation(Bill):
+    _amount = models.DecimalField(max_digits=8, decimal_places=2,
+                                  verbose_name='Montant')
+
+    @property
+    def amount(self):
+        return self._amount
+    amount.fget.short_description = 'Montant'
+
+    @property
+    def validated(self):
+        return True
+
+    def save(self, *args, **kwargs):
+
+        super(Donation, self).save(*args, **kwargs)
+
+    def clean(self):
+        # Only if no amount already allocated...
+        if not self.member or self.member.balance < self.amount:
+            raise ValidationError("Le solde n'est pas suffisant pour payer ce don. \
+                        Merci de commencer par enregistrer un paiement pour ce membre.")
+    class Meta:
+        verbose_name = 'don'
+
+class MembershipFee(Bill):
+    _amount = models.DecimalField(null=False, max_digits=5, decimal_places=2,
+                                 default=settings.MEMBER_DEFAULT_COTISATION,
+                                 verbose_name='montant', help_text='en €')
+    start_date = models.DateField(
+        null=False,
+        blank=False,
+        verbose_name='date de début de cotisation')
+    end_date = models.DateField(
+        null=False,
+        blank=True,
+        verbose_name='date de fin de cotisation',
+        help_text='par défaut, la cotisation dure un an')
+
+    @property
+    def amount(self):
+        return self._amount
+    amount.fget.short_description = 'Montant'
+    @property
+    def validated(self):
+        return True
+
+    def save(self, *args, **kwargs):
+
+        super(MembershipFee, self).save(*args, **kwargs)
+
+
+    def clean(self):
+        if self.start_date is not None and self.end_date is None:
+            self.end_date = self.start_date + datetime.timedelta(364)
+        # Only if no amount already allocated...
+        if not self.member or self.member.balance < self.amount:
+            raise ValidationError("Le solde n'est pas suffisant pour payer cette cotisation. \
+                        Merci de commencer par enregistrer un paiement pour ce membre.")
+
+    class Meta:
+        verbose_name = 'cotisation'
+
 class Payment(models.Model):
 class Payment(models.Model):
 
 
     PAYMENT_MEAN_CHOICES = (
     PAYMENT_MEAN_CHOICES = (
@@ -370,7 +500,7 @@ class Payment(models.Model):
     amount = models.DecimalField(max_digits=6, decimal_places=2, null=True,
     amount = models.DecimalField(max_digits=6, decimal_places=2, null=True,
                                  verbose_name='montant')
                                  verbose_name='montant')
     date = models.DateField(default=datetime.date.today)
     date = models.DateField(default=datetime.date.today)
-    invoice = models.ForeignKey(Invoice, verbose_name='facture associée', null=True,
+    bill = models.ForeignKey(Bill, verbose_name='facture associée', null=True,
                                 blank=True, related_name='payments')
                                 blank=True, related_name='payments')
 
 
     label = models.CharField(max_length=500,
     label = models.CharField(max_length=500,
@@ -383,9 +513,9 @@ class Payment(models.Model):
         if self.amount_already_allocated() == 0:
         if self.amount_already_allocated() == 0:
 
 
             # If there's a linked invoice and no member defined
             # If there's a linked invoice and no member defined
-            if self.invoice and not self.member:
+            if self.bill and not self.member:
                 # Automatically set member to invoice's member
                 # Automatically set member to invoice's member
-                self.member = self.invoice.member
+                self.member = self.bill.member
 
 
         super(Payment, self).save(*args, **kwargs)
         super(Payment, self).save(*args, **kwargs)
 
 
@@ -397,7 +527,7 @@ class Payment(models.Model):
 
 
             # If there's a linked invoice and this payment would pay more than
             # If there's a linked invoice and this payment would pay more than
             # the remaining amount needed to pay the invoice...
             # the remaining amount needed to pay the invoice...
-            if self.invoice and self.amount > self.invoice.amount_remaining_to_pay():
+            if self.bill and self.amount > self.bill.amount_remaining_to_pay():
                 raise ValidationError("This payment would pay more than the invoice's remaining to pay")
                 raise ValidationError("This payment would pay more than the invoice's remaining to pay")
 
 
     def amount_already_allocated(self):
     def amount_already_allocated(self):
@@ -407,30 +537,30 @@ class Payment(models.Model):
         return self.amount - self.amount_already_allocated()
         return self.amount - self.amount_already_allocated()
 
 
     @transaction.atomic
     @transaction.atomic
-    def allocate_to_invoice(self, invoice):
+    def allocate_to_bill(self, bill):
 
 
         # FIXME - Add asserts about remaining amount > 0, unpaid amount > 0,
         # FIXME - Add asserts about remaining amount > 0, unpaid amount > 0,
         # ...
         # ...
 
 
         amount_can_pay = self.amount_not_allocated()
         amount_can_pay = self.amount_not_allocated()
-        amount_to_pay  = invoice.amount_remaining_to_pay()
+        amount_to_pay  = bill.amount_remaining_to_pay()
         amount_to_allocate = min(amount_can_pay, amount_to_pay)
         amount_to_allocate = min(amount_can_pay, amount_to_pay)
 
 
-        accounting_log.info("Allocating %f from payment %s to invoice %s"
+        accounting_log.info("Allocating %f from payment %s to bill %s %s"
                             % (float(amount_to_allocate), str(self.date),
                             % (float(amount_to_allocate), str(self.date),
-                               invoice.number))
+                               bill.reference, bill.pk))
 
 
-        PaymentAllocation.objects.create(invoice=invoice,
+        PaymentAllocation.objects.create(bill=bill,
                                          payment=self,
                                          payment=self,
                                          amount=amount_to_allocate)
                                          amount=amount_to_allocate)
 
 
         # Close invoice if relevant
         # Close invoice if relevant
-        if (invoice.amount_remaining_to_pay() <= 0) and (invoice.status == "open"):
-            accounting_log.info("Invoice %s has been paid and is now closed"
-                                % invoice.number)
-            invoice.status = "closed"
+        if (bill.amount_remaining_to_pay() <= 0) and (bill.status == "open"):
+            accounting_log.info("Bill %s %s has been paid and is now closed"
+                                % (bill.reference, bill.pk))
+            bill.status = "closed"
 
 
-        invoice.save()
+        bill.save()
         self.save()
         self.save()
 
 
     def __unicode__(self):
     def __unicode__(self):
@@ -451,7 +581,7 @@ class Payment(models.Model):
 # There can be for example an allocation of 3.14€ from P to I.
 # There can be for example an allocation of 3.14€ from P to I.
 class PaymentAllocation(models.Model):
 class PaymentAllocation(models.Model):
 
 
-    invoice = models.ForeignKey(Invoice, verbose_name='facture associée',
+    bill = models.ForeignKey(Bill, verbose_name='facture associée',
                                 null=False, blank=False,
                                 null=False, blank=False,
                                 related_name='allocations')
                                 related_name='allocations')
     payment = models.ForeignKey(Payment, verbose_name='facture associée',
     payment = models.ForeignKey(Payment, verbose_name='facture associée',
@@ -461,21 +591,21 @@ class PaymentAllocation(models.Model):
                                  verbose_name='montant')
                                  verbose_name='montant')
 
 
 
 
-def get_active_payment_and_invoices(member):
+def get_active_payment_and_bills(member):
 
 
     # Fetch relevant and active payments / invoices
     # Fetch relevant and active payments / invoices
     # and sort then by chronological order : olders first, newers last.
     # and sort then by chronological order : olders first, newers last.
 
 
-    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_bills = Bill.get_member_validated_bills(member)
     this_member_payments = [p for p in member.payments.order_by("date")]
     this_member_payments = [p for p in member.payments.order_by("date")]
 
 
     # TODO / FIXME ^^^ maybe also consider only 'opened' invoices (i.e. not
     # TODO / FIXME ^^^ maybe also consider only 'opened' invoices (i.e. not
     # conflict / trouble invoices)
     # conflict / trouble invoices)
 
 
     active_payments = [p for p in this_member_payments if p.amount_not_allocated()    > 0]
     active_payments = [p for p in this_member_payments if p.amount_not_allocated()    > 0]
-    active_invoices = [p for p in this_member_invoices if p.amount_remaining_to_pay() > 0]
+    active_bills = [p for p in this_member_bills if p.amount_remaining_to_pay() > 0]
 
 
-    return active_payments, active_invoices
+    return active_payments, active_bills
 
 
 
 
 def update_accounting_for_member(member):
 def update_accounting_for_member(member):
@@ -489,12 +619,12 @@ def update_accounting_for_member(member):
     accounting_log.info("Member %s current balance is %f ..."
     accounting_log.info("Member %s current balance is %f ..."
                         % (member, float(member.balance)))
                         % (member, float(member.balance)))
 
 
-    reconcile_invoices_and_payments(member)
+    reconcile_bills_and_payments(member)
 
 
-    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_bills = Bill.get_member_validated_bills(member)
     this_member_payments = [p for p in member.payments.order_by("date")]
     this_member_payments = [p for p in member.payments.order_by("date")]
 
 
-    member.balance = compute_balance(this_member_invoices,
+    member.balance = compute_balance(this_member_bills,
                                      this_member_payments)
                                      this_member_payments)
     member.save()
     member.save()
 
 
@@ -502,21 +632,21 @@ def update_accounting_for_member(member):
                         % (member,  float(member.balance)))
                         % (member,  float(member.balance)))
 
 
 
 
-def reconcile_invoices_and_payments(member):
+def reconcile_bills_and_payments(member):
     """
     """
     Rapproche des factures et des paiements qui sont actifs (paiement non alloué
     Rapproche des factures et des paiements qui sont actifs (paiement non alloué
     ou factures non entièrement payées) automatiquement.
     ou factures non entièrement payées) automatiquement.
     """
     """
 
 
-    active_payments, active_invoices = get_active_payment_and_invoices(member)
+    active_payments, active_bills = get_active_payment_and_bills(member)
 
 
     if active_payments == []:
     if active_payments == []:
-        accounting_log.info("(No active payment for %s. No invoice/payment "
+        accounting_log.info("(No active payment for %s. No bill/payment "
                             "reconciliation needed.)."
                             "reconciliation needed.)."
                             % member)
                             % member)
         return
         return
-    elif active_invoices == []:
-        accounting_log.info("(No active invoice for %s. No invoice/payment "
+    elif active_bills == []:
+        accounting_log.info("(No active bill for %s. No bill/payment "
                             "reconciliation needed.)."
                             "reconciliation needed.)."
                             % member)
                             % member)
         return
         return
@@ -524,31 +654,31 @@ def reconcile_invoices_and_payments(member):
     accounting_log.info("Initiating reconciliation between "
     accounting_log.info("Initiating reconciliation between "
                         "invoice and payments for %s" % member)
                         "invoice and payments for %s" % member)
 
 
-    while active_payments != [] and active_invoices != []:
+    while active_payments != [] and active_bills != []:
 
 
-        # Only consider the oldest active payment and the oldest active invoice
+        # Only consider the oldest active payment and the oldest active bill
         p = active_payments[0]
         p = active_payments[0]
 
 
-        # If this payment is to be allocated for a specific invoice...
-        if p.invoice:
+        # If this payment is to be allocated for a specific bill...
+        if p.bill:
             # Assert that the invoice is still 'active'
             # Assert that the invoice is still 'active'
-            assert p.invoice in active_invoices
-            i = p.invoice
+            assert p.bill in active_bills
+            i = p.bill
             accounting_log.info("Payment is to be allocated specifically to " \
             accounting_log.info("Payment is to be allocated specifically to " \
-                                "invoice %s" % str(i.number))
+                                "bill %s" % str(i.pk))
         else:
         else:
-            i = active_invoices[0]
+            i = active_bills[0]
 
 
         # TODO : should add an assert that the ammount not allocated / remaining to
         # TODO : should add an assert that the ammount not allocated / remaining to
         # pay is lower before and after calling the allocate_to_invoice
         # pay is lower before and after calling the allocate_to_invoice
 
 
-        p.allocate_to_invoice(i)
+        p.allocate_to_bill(i)
 
 
-        active_payments, active_invoices = get_active_payment_and_invoices(member)
+        active_payments, active_bills = get_active_payment_and_bills(member)
 
 
     if active_payments == []:
     if active_payments == []:
         accounting_log.info("No more active payment. Nothing to reconcile anymore.")
         accounting_log.info("No more active payment. Nothing to reconcile anymore.")
-    elif active_invoices == []:
+    elif active_bills == []:
         accounting_log.info("No more active invoice. Nothing to reconcile anymore.")
         accounting_log.info("No more active invoice. Nothing to reconcile anymore.")
     return
     return
 
 
@@ -586,31 +716,34 @@ def payment_changed(sender, instance, created, **kwargs):
         update_accounting_for_member(instance.member)
         update_accounting_for_member(instance.member)
 
 
 
 
-@receiver(post_save, sender=Invoice)
+@receiver(post_save, sender=Bill)
 @disable_for_loaddata
 @disable_for_loaddata
-def invoice_changed(sender, instance, created, **kwargs):
+def bill_changed(sender, instance, created, **kwargs):
 
 
-    if created:
-        accounting_log.info("Creating draft invoice %s (Member: %s)."
-                            % ('DRAFT-{}'.format(instance.pk), instance.member))
-    else:
-        if not instance.validated:
-            accounting_log.info("Updating draft invoice %s (Member: %s)."
-                    % (instance.number, instance.member))
-        else:
-            accounting_log.info("Updating invoice %s (Member: %s, Total amount: %s, Amount paid: %s)."
-                    % (instance.number, instance.member, instance.amount(), instance.amount_paid() ))
+    instance.log_change(created)
+
+@receiver(post_save, sender=MembershipFee)
+@disable_for_loaddata
+def fee_changed(sender, instance, created, **kwargs):
+    if created and instance.member is not None:
+        update_accounting_for_member(instance.member)
+
+@receiver(post_save, sender=Donation)
+@disable_for_loaddata
+def fee_changed(sender, instance, created, **kwargs):
+    if created and instance.member is not None:
+        update_accounting_for_member(instance.member)
 
 
 @receiver(post_delete, sender=PaymentAllocation)
 @receiver(post_delete, sender=PaymentAllocation)
 def paymentallocation_deleted(sender, instance, **kwargs):
 def paymentallocation_deleted(sender, instance, **kwargs):
 
 
-    invoice = instance.invoice
+    bill = instance.bill
 
 
     # Reopen invoice if relevant
     # Reopen invoice if relevant
-    if (invoice.amount_remaining_to_pay() > 0) and (invoice.status == "closed"):
-        accounting_log.info("Reopening invoice %s ..." % invoice.number)
-        invoice.status = "open"
-        invoice.save()
+    if (bill.amount_remaining_to_pay() > 0) and (bill.status == "closed"):
+        accounting_log.info("Reopening bill %s ..." % bill.number)
+        bill.status = "open"
+        bill.save()
 
 
 
 
 @receiver(post_delete, sender=Payment)
 @receiver(post_delete, sender=Payment)
@@ -625,10 +758,10 @@ def payment_deleted(sender, instance, **kwargs):
     if member is None:
     if member is None:
         return
         return
 
 
-    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_bills = Bill.get_member_validated_bills(member)
     this_member_payments = [p for p in member.payments.order_by("date")]
     this_member_payments = [p for p in member.payments.order_by("date")]
 
 
-    member.balance = compute_balance(this_member_invoices,
+    member.balance = compute_balance(this_member_bills,
                                      this_member_payments)
                                      this_member_payments)
     member.save()
     member.save()
 
 

+ 163 - 6
coin/billing/tests.py

@@ -9,7 +9,7 @@ from django.test import TestCase, Client
 from freezegun import freeze_time
 from freezegun import freeze_time
 from coin.members.tests import MemberTestsUtils
 from coin.members.tests import MemberTestsUtils
 from coin.members.models import Member, LdapUser
 from coin.members.models import Member, LdapUser
-from coin.billing.models import Invoice, InvoiceQuerySet, InvoiceDetail, Payment
+from coin.billing.models import Invoice, InvoiceQuerySet, InvoiceDetail, Payment, MembershipFee
 from coin.offers.models import Offer, OfferSubscription
 from coin.offers.models import Offer, OfferSubscription
 from coin.billing.create_subscriptions_invoices import create_member_invoice_for_a_period
 from coin.billing.create_subscriptions_invoices import create_member_invoice_for_a_period
 from coin.billing.create_subscriptions_invoices import create_all_members_invoices_for_a_period
 from coin.billing.create_subscriptions_invoices import create_all_members_invoices_for_a_period
@@ -133,7 +133,7 @@ class BillingInvoiceCreationTests(TestCase):
                                period_to=datetime.date(2014, 8, 31),
                                period_to=datetime.date(2014, 8, 31),
                                tax=10)
                                tax=10)
 
 
-        self.assertEqual(invoice.amount(), 111)
+        self.assertEqual(invoice.amount, 111)
 
 
     def test_invoice_partial_payment(self):
     def test_invoice_partial_payment(self):
         invoice = Invoice(member=self.member)
         invoice = Invoice(member=self.member)
@@ -150,7 +150,7 @@ class BillingInvoiceCreationTests(TestCase):
 
 
         self.assertEqual(invoice.status, 'open')
         self.assertEqual(invoice.status, 'open')
         p1 = Payment.objects.create(member=self.member,
         p1 = Payment.objects.create(member=self.member,
-                                    invoice=invoice,
+                                    bill=invoice,
                                     payment_mean='cash',
                                     payment_mean='cash',
                                     amount=10)
                                     amount=10)
         p1.save()
         p1.save()
@@ -159,7 +159,7 @@ class BillingInvoiceCreationTests(TestCase):
         self.assertEqual(invoice.status, 'open')
         self.assertEqual(invoice.status, 'open')
 
 
         p2 = Payment.objects.create(member=self.member,
         p2 = Payment.objects.create(member=self.member,
-                                    invoice=invoice,
+                                    bill=invoice,
                                     payment_mean='cash',
                                     payment_mean='cash',
                                     amount=90)
                                     amount=90)
         p2.save()
         p2.save()
@@ -365,7 +365,7 @@ class PaymentInvoiceAutoReconciliationTests(TestCase):
                                          member=johndoe)
                                          member=johndoe)
         InvoiceDetail.objects.create(label="superservice",
         InvoiceDetail.objects.create(label="superservice",
                                      amount="15.0",
                                      amount="15.0",
-                                     invoice=invoice)
+                                     bill=invoice)
         invoice.validate()
         invoice.validate()
 
 
         # Second facture
         # Second facture
@@ -373,7 +373,7 @@ class PaymentInvoiceAutoReconciliationTests(TestCase):
                                          member=johndoe)
                                          member=johndoe)
         InvoiceDetail.objects.create(label="superservice",
         InvoiceDetail.objects.create(label="superservice",
                                      amount="42",
                                      amount="42",
-                                     invoice=invoice2)
+                                     bill=invoice2)
         invoice2.validate()
         invoice2.validate()
 
 
         # Payment
         # Payment
@@ -386,3 +386,160 @@ class PaymentInvoiceAutoReconciliationTests(TestCase):
         johndoe.delete()
         johndoe.delete()
 
 
 
 
+class MembershipFeeTests(TestCase):
+    def test_mandatory_start_date(self):
+        member = Member(first_name='foo', last_name='foo', password='foo', email='foo')
+        member.save()
+
+        # If there is no start_date clean_fields() should raise an
+        # error but not clean().
+        membershipfee = MembershipFee(member=member)
+        self.assertRaises(ValidationError, membershipfee.clean_fields)
+        self.assertIsNone(membershipfee.clean())
+
+        # If there is a start_date, everything is fine.
+        membershipfee = MembershipFee(member=member, start_date=date.today())
+        self.assertIsNone(membershipfee.clean_fields())
+        self.assertIsNone(membershipfee.clean())
+
+        member.delete()
+
+    def test_member_end_date_of_memberhip(self):
+        """
+        Test que end_date_of_membership d'un membre envoi bien la date de fin d'adhésion
+        """
+        # Créer un membre
+        first_name = 'Tin'
+        last_name = 'Tin'
+        username = MemberTestsUtils.get_random_username()
+        member = Member(first_name=first_name,
+                        last_name=last_name, username=username)
+        member.save()
+
+        start_date = date.today()
+        end_date = start_date + relativedelta(years=+1)
+
+        # Créé une cotisation
+        membershipfee = MembershipFee(member=member, amount=20,
+                                      start_date=start_date,
+                                      end_date=end_date)
+        membershipfee.save()
+
+        self.assertEqual(member.end_date_of_membership(), end_date)
+
+    def test_member_is_paid_up(self):
+        """
+        Test l'état "a jour de cotisation" d'un adhérent.
+        """
+        # Créé un membre
+        first_name = 'Capitain'
+        last_name = 'Haddock'
+        username = MemberTestsUtils.get_random_username()
+        member = Member(first_name=first_name,
+                        last_name=last_name, username=username)
+        member.save()
+
+        start_date = date.today()
+        end_date = start_date + relativedelta(years=+1)
+
+        # Test qu'un membre sans cotisation n'est pas à jour
+        self.assertEqual(member.is_paid_up(), False)
+
+        # Créé une cotisation passée
+        membershipfee = MembershipFee(member=member, amount=20,
+                                      start_date=date.today() +
+                                      relativedelta(years=-1),
+                                      end_date=date.today() + relativedelta(days=-10))
+        membershipfee.save()
+        # La cotisation s'étant terminée il y a 10 jours, il ne devrait pas
+        # être à jour de cotistion
+        self.assertEqual(member.is_paid_up(), False)
+
+        # Créé une cotisation actuelle
+        membershipfee = MembershipFee(member=member, amount=20,
+                                      start_date=date.today() +
+                                      relativedelta(days=-10),
+                                      end_date=date.today() + relativedelta(days=+10))
+        membershipfee.save()
+        # La cotisation se terminant dans 10 jour, il devrait être à jour
+        # de cotisation
+        self.assertEqual(member.is_paid_up(), True)
+
+
+class MemberTestCallForMembershipCommand(TestCase):
+
+    def setUp(self):
+        # Créé un membre
+        self.username = MemberTestsUtils.get_random_username()
+        self.member = Member(first_name='Richard', last_name='Stallman',
+                             username=self.username)
+        self.member.save()
+
+
+    def tearDown(self):
+        # Supprime le membre
+        self.member.delete()
+        MembershipFee.objects.all().delete()
+
+    def create_membership_fee(self, end_date):
+        # Créé une cotisation passée se terminant dans un mois
+        membershipfee = MembershipFee(member=self.member, amount=20,
+                                      start_date=end_date + relativedelta(years=-1),
+                                      end_date=end_date)
+        membershipfee.save()
+
+    def create_membership_fee(self, end_date):
+        # Créé une cotisation se terminant à la date indiquée
+        membershipfee = MembershipFee(member=self.member, amount=20,
+                                      start_date=end_date + relativedelta(years=-1),
+                                      end_date=end_date)
+        membershipfee.save()
+        return membershipfee
+
+    def do_test_email_sent(self, expected_emails = 1, reset_date_last_call = True):
+        # Vide la outbox
+        mail.outbox = []
+        # Call command
+        management.call_command('call_for_membership_fees', stdout=StringIO())
+        # Test
+        self.assertEqual(len(mail.outbox), expected_emails)
+        # Comme on utilise le même membre, on reset la date de dernier envoi
+        if reset_date_last_call:
+            self.member.date_last_call_for_membership_fees_email = None
+            self.member.save()
+
+    def do_test_for_a_end_date(self, end_date, expected_emails=1, reset_date_last_call = True):
+        # Supprimer toutes les cotisations (au cas ou)
+        MembershipFee.objects.all().delete()
+        # Créé la cotisation
+        membershipfee = self.create_membership_fee(end_date)
+        self.do_test_email_sent(expected_emails, reset_date_last_call)
+        membershipfee.delete()
+
+    def test_call_email_sent_at_expected_dates(self):
+        # 1 mois avant la fin, à la fin et chaque mois après la fin pendant 3 mois
+        self.do_test_for_a_end_date(date.today() + relativedelta(months=+1))
+        self.do_test_for_a_end_date(date.today())
+        self.do_test_for_a_end_date(date.today() + relativedelta(months=-1))
+        self.do_test_for_a_end_date(date.today() + relativedelta(months=-2))
+        self.do_test_for_a_end_date(date.today() + relativedelta(months=-3))
+
+    def test_call_email_not_sent_if_active_membership_fee(self):
+        # Créé une cotisation se terminant dans un mois
+        membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1))
+        # Un mail devrait être envoyé (ne pas vider date_last_call_for_membership_fees_email)
+        self.do_test_email_sent(1, False)
+        # Créé une cotisation enchainant et se terminant dans un an
+        membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1, years=+1))
+        # Pas de mail envoyé
+        self.do_test_email_sent(0)
+
+    def test_date_last_call_for_membership_fees_email(self):
+        # Créé une cotisation se terminant dans un mois
+        membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1))
+        # Un mail envoyé (ne pas vider date_last_call_for_membership_fees_email)
+        self.do_test_email_sent(1, False)
+        # Tente un deuxième envoi, qui devrait être à 0
+        self.do_test_email_sent(0)
+
+

+ 2 - 2
coin/billing/urls.py

@@ -7,8 +7,8 @@ from coin.billing import views
 
 
 urlpatterns = patterns(
 urlpatterns = patterns(
     '',
     '',
-    url(r'^invoice/(?P<id>.+)/pdf$', views.invoice_pdf, name="invoice_pdf"),
-    url(r'^invoice/(?P<id>.+)$', views.invoice, name="invoice"),
+    url(r'^bill/(?P<id>.+)/pdf$', views.invoice_pdf, name="bill_pdf"),
+    url(r'^bill/(?P<id>.+)$', views.invoice, name="bill"),
     # url(r'^invoice/(?P<id>.+)/validate$', views.invoice_validate, name="invoice_validate"),
     # url(r'^invoice/(?P<id>.+)/validate$', views.invoice_validate, name="invoice_validate"),
 
 
     url('invoice/create_all_members_invoices_for_a_period', views.gen_invoices)
     url('invoice/create_all_members_invoices_for_a_period', views.gen_invoices)

+ 1 - 1
coin/billing/utils.py

@@ -23,4 +23,4 @@ def assert_user_can_view_the_invoice(request, invoice):
     """
     """
     if not invoice.has_owner(request.user.username)\
     if not invoice.has_owner(request.user.username)\
        and not request.user.is_superuser:
        and not request.user.is_superuser:
-        raise PermissionDenied
+        raise PermissionDenied

+ 3 - 18
coin/members/admin.py

@@ -14,25 +14,16 @@ from django.core.urlresolvers import reverse
 from django.utils.html import format_html
 from django.utils.html import format_html
 
 
 from coin.members.models import (
 from coin.members.models import (
-    Member, CryptoKey, LdapUser, MembershipFee, Offer, OfferSubscription, RowLevelPermission)
-from coin.members.membershipfee_filter import MembershipFeeFilter
+    Member, CryptoKey, LdapUser, Offer, OfferSubscription, RowLevelPermission)
 from coin.members.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.members.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.utils import delete_selected
 from coin.utils import delete_selected
 import autocomplete_light
 import autocomplete_light
 
 
-
 class CryptoKeyInline(admin.StackedInline):
 class CryptoKeyInline(admin.StackedInline):
     model = CryptoKey
     model = CryptoKey
     extra = 0
     extra = 0
 
 
 
 
-class MembershipFeeInline(admin.TabularInline):
-    model = MembershipFee
-    extra = 0
-    fields = ('start_date', 'end_date', 'amount', 'payment_method',
-              'reference', 'payment_date')
-
-
 class OfferSubscriptionInline(admin.TabularInline):
 class OfferSubscriptionInline(admin.TabularInline):
     model = OfferSubscription
     model = OfferSubscription
     extra = 0
     extra = 0
@@ -87,7 +78,7 @@ class MemberAdmin(UserAdmin):
                     'nickname', 'organization_name', 'email',
                     'nickname', 'organization_name', 'email',
                     'end_date_of_membership')
                     'end_date_of_membership')
     list_display_links = ('id', 'username', 'first_name', 'last_name')
     list_display_links = ('id', 'username', 'first_name', 'last_name')
-    list_filter = ('status', MembershipFeeFilter)
+    list_filter = ()
     search_fields = ['username', 'first_name', 'last_name', 'email', 'nickname']
     search_fields = ['username', 'first_name', 'last_name', 'email', 'nickname']
     ordering = ('status', 'username')
     ordering = ('status', 'username')
     actions = [delete_selected, 'set_as_member', 'set_as_non_member',
     actions = [delete_selected, 'set_as_member', 'set_as_non_member',
@@ -141,7 +132,7 @@ class MemberAdmin(UserAdmin):
 
 
     save_on_top = True
     save_on_top = True
 
 
-    inlines = [CryptoKeyInline, MembershipFeeInline, OfferSubscriptionInline]
+    inlines = [CryptoKeyInline, OfferSubscriptionInline]
 
 
     def get_queryset(self, request):
     def get_queryset(self, request):
         qs = super(MemberAdmin, self).get_queryset(request)
         qs = super(MemberAdmin, self).get_queryset(request)
@@ -256,11 +247,6 @@ class MemberAdmin(UserAdmin):
     bulk_send_call_for_membership_fee_email.short_description = 'Envoyer le courriel de relance de cotisation'
     bulk_send_call_for_membership_fee_email.short_description = 'Envoyer le courriel de relance de cotisation'
 
 
 
 
-class MembershipFeeAdmin(admin.ModelAdmin):
-    list_display = ('member', 'end_date', 'amount', 'payment_method',
-                    'payment_date')
-    form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
-
 class RowLevelPermissionAdmin(admin.ModelAdmin):
 class RowLevelPermissionAdmin(admin.ModelAdmin):
     def get_changeform_initial_data(self, request):
     def get_changeform_initial_data(self, request):
         return {'content_type': ContentType.objects.get_for_model(OfferSubscription)}
         return {'content_type': ContentType.objects.get_for_model(OfferSubscription)}
@@ -268,7 +254,6 @@ class RowLevelPermissionAdmin(admin.ModelAdmin):
 
 
 
 
 admin.site.register(Member, MemberAdmin)
 admin.site.register(Member, MemberAdmin)
-admin.site.register(MembershipFee, MembershipFeeAdmin)
 # admin.site.unregister(Group)
 # admin.site.unregister(Group)
 # admin.site.register(LdapUser, LdapUserAdmin)
 # admin.site.register(LdapUser, LdapUserAdmin)
 admin.site.register(RowLevelPermission, RowLevelPermissionAdmin)
 admin.site.register(RowLevelPermission, RowLevelPermissionAdmin)

+ 30 - 0
coin/members/migrations/0018_auto_20180414_2250.py

@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import coin.members.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0017_merge'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='rowlevelpermission',
+            options={'verbose_name': 'permission fine', 'verbose_name_plural': 'permissions fines'},
+        ),
+        migrations.AlterModelManagers(
+            name='member',
+            managers=[
+                ('objects', coin.members.models.MemberManager()),
+            ],
+        ),
+        migrations.AlterField(
+            model_name='member',
+            name='balance',
+            field=models.DecimalField(default=0, verbose_name='account balance', max_digits=6, decimal_places=2),
+        ),
+    ]

+ 21 - 0
coin/members/migrations/0019_auto_20180415_1814.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0018_auto_20180414_2250'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='membershipfee',
+            name='member',
+        ),
+        migrations.DeleteModel(
+            name='MembershipFee',
+        ),
+    ]

+ 3 - 45
coin/members/models.py

@@ -153,7 +153,9 @@ class Member(CoinLdapSyncMixin, AbstractUser):
 
 
     # Renvoie la date de fin de la dernière cotisation du membre
     # Renvoie la date de fin de la dernière cotisation du membre
     def end_date_of_membership(self):
     def end_date_of_membership(self):
-        aggregate = self.membership_fees.aggregate(end=Max('end_date'))
+        # Avoid import loop
+        from coin.billing.models import MembershipFee
+        aggregate = MembershipFee.objects.filter(member=self).aggregate(end=Max('end_date'))
         return aggregate['end']
         return aggregate['end']
     end_date_of_membership.short_description = "Date de fin d'adhésion"
     end_date_of_membership.short_description = "Date de fin d'adhésion"
 
 
@@ -438,50 +440,6 @@ class CryptoKey(CoinLdapSyncMixin, models.Model):
         verbose_name = 'clé'
         verbose_name = 'clé'
 
 
 
 
-class MembershipFee(models.Model):
-    PAYMENT_METHOD_CHOICES = (
-        ('cash', 'Espèces'),
-        ('check', 'Chèque'),
-        ('transfer', 'Virement'),
-        ('other', 'Autre')
-    )
-
-    member = models.ForeignKey('Member', related_name='membership_fees',
-                               verbose_name='membre')
-    amount = models.DecimalField(null=False, max_digits=5, decimal_places=2,
-                                 default=settings.MEMBER_DEFAULT_COTISATION,
-                                 verbose_name='montant', help_text='en €')
-    start_date = models.DateField(
-        null=False,
-        blank=False,
-        verbose_name='date de début de cotisation')
-    end_date = models.DateField(
-        null=False,
-        blank=True,
-        verbose_name='date de fin de cotisation',
-        help_text='par défaut, la cotisation dure un an')
-
-    payment_method = models.CharField(max_length=100, null=True, blank=True,
-                                      choices=PAYMENT_METHOD_CHOICES,
-                                      verbose_name='moyen de paiement')
-    reference = models.CharField(max_length=125, null=True, blank=True,
-                                 verbose_name='référence du paiement',
-                                 help_text='numéro de chèque, '
-                                 'référence de virement, commentaire...')
-    payment_date = models.DateField(null=True, blank=True,
-                                    verbose_name='date du paiement')
-
-    def clean(self):
-        if self.start_date is not None and self.end_date is None:
-            self.end_date = self.start_date + datetime.timedelta(364)
-
-    def __unicode__(self):
-        return '%s - %s - %i€' % (self.member, self.start_date, self.amount)
-
-    class Meta:
-        verbose_name = 'cotisation'
-
-
 class LdapUser(ldapdb.models.Model):
 class LdapUser(ldapdb.models.Model):
     # "ou=users,ou=unix,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
     # "ou=users,ou=unix,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
     base_dn = settings.LDAP_USER_BASE_DN
     base_dn = settings.LDAP_USER_BASE_DN

+ 4 - 4
coin/members/templates/members/invoices.html

@@ -4,12 +4,12 @@
 
 
 <h2>Balance : {{ balance|floatformat }} €</h2>
 <h2>Balance : {{ balance|floatformat }} €</h2>
 
 
-<h2>Mes factures</h2>
+<h2>Mes factures et reçus</h2>
 
 
 <table id="member_invoices" class="full-width">
 <table id="member_invoices" class="full-width">
     <thead>
     <thead>
         <tr>
         <tr>
-            <th>Numéro</th>
+            <th>Référence</th>
             <th>Date</th>
             <th>Date</th>
             <th>Montant</th>
             <th>Montant</th>
             <th>Reste à payer</th>
             <th>Reste à payer</th>
@@ -19,11 +19,11 @@
     <tbody>
     <tbody>
         {% for invoice in invoices %}
         {% for invoice in invoices %}
         <tr>
         <tr>
-            <td><a href="{% url 'billing:invoice' id=invoice.number %}">{{ invoice.number }}</a></td>
+            <td><a href="{% url 'billing:bill' id=invoice.pk %}">{{ invoice.reference }}</a></td>
             <td>{{ invoice.date }}</td>
             <td>{{ invoice.date }}</td>
             <td>{{ invoice.amount }}</td>
             <td>{{ invoice.amount }}</td>
             <td{% if invoice.amount_remaining_to_pay > 0 %} class="unpaid"{% endif %}>{{ invoice.amount_remaining_to_pay }}</td>
             <td{% if invoice.amount_remaining_to_pay > 0 %} class="unpaid"{% endif %}>{{ invoice.amount_remaining_to_pay }}</td>
-            <td>{% if invoice.validated %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}"><i class="fa fa-file-pdf-o"></i> PDF</a>{% endif %}</td>
+            <td>{% if invoice.validated %}<a href="{% url 'billing:bill_pdf' id=invoice.pk %}"><i class="fa fa-file-pdf-o"></i> PDF</a>{% endif %}</td>
         </tr>
         </tr>
         {% empty %}
         {% empty %}
         <tr class="placeholder"><td colspan="6">Aucune facture.</td></tr>
         <tr class="placeholder"><td colspan="6">Aucune facture.</td></tr>

+ 1 - 158
coin/members/tests.py

@@ -16,7 +16,7 @@ from django.contrib.auth.models import User
 from django.core import mail, management
 from django.core import mail, management
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 
 
-from coin.members.models import Member, MembershipFee, LdapUser
+from coin.members.models import Member, LdapUser
 from coin.validation import chatroom_url_validator
 from coin.validation import chatroom_url_validator
 
 
 
 
@@ -298,67 +298,6 @@ class MemberTests(TestCase):
 
 
         member.delete()
         member.delete()
 
 
-    def test_member_end_date_of_memberhip(self):
-        """
-        Test que end_date_of_membership d'un membre envoi bien la date de fin d'adhésion
-        """
-        # Créer un membre
-        first_name = 'Tin'
-        last_name = 'Tin'
-        username = MemberTestsUtils.get_random_username()
-        member = Member(first_name=first_name,
-                        last_name=last_name, username=username)
-        member.save()
-
-        start_date = date.today()
-        end_date = start_date + relativedelta(years=+1)
-
-        # Créé une cotisation
-        membershipfee = MembershipFee(member=member, amount=20,
-                                      start_date=start_date,
-                                      end_date=end_date)
-        membershipfee.save()
-
-        self.assertEqual(member.end_date_of_membership(), end_date)
-
-    def test_member_is_paid_up(self):
-        """
-        Test l'état "a jour de cotisation" d'un adhérent.
-        """
-        # Créé un membre
-        first_name = 'Capitain'
-        last_name = 'Haddock'
-        username = MemberTestsUtils.get_random_username()
-        member = Member(first_name=first_name,
-                        last_name=last_name, username=username)
-        member.save()
-
-        start_date = date.today()
-        end_date = start_date + relativedelta(years=+1)
-
-        # Test qu'un membre sans cotisation n'est pas à jour
-        self.assertEqual(member.is_paid_up(), False)
-
-        # Créé une cotisation passée
-        membershipfee = MembershipFee(member=member, amount=20,
-                                      start_date=date.today() +
-                                      relativedelta(years=-1),
-                                      end_date=date.today() + relativedelta(days=-10))
-        membershipfee.save()
-        # La cotisation s'étant terminée il y a 10 jours, il ne devrait pas
-        # être à jour de cotistion
-        self.assertEqual(member.is_paid_up(), False)
-
-        # Créé une cotisation actuelle
-        membershipfee = MembershipFee(member=member, amount=20,
-                                      start_date=date.today() +
-                                      relativedelta(days=-10),
-                                      end_date=date.today() + relativedelta(days=+10))
-        membershipfee.save()
-        # La cotisation se terminant dans 10 jour, il devrait être à jour
-        # de cotisation
-        self.assertEqual(member.is_paid_up(), True)
-
     def test_member_cant_be_created_without_names(self):
     def test_member_cant_be_created_without_names(self):
         """
         """
         Test qu'un membre ne peut pas être créé sans "noms"
         Test qu'un membre ne peut pas être créé sans "noms"
@@ -374,7 +313,6 @@ class MemberTests(TestCase):
             member.save()
             member.save()
 
 
 
 
-
 class MemberAdminTests(TestCase):
 class MemberAdminTests(TestCase):
 
 
     def setUp(self):
     def setUp(self):
@@ -417,83 +355,6 @@ class MemberAdminTests(TestCase):
         member.delete()
         member.delete()
 
 
 
 
-class MemberTestCallForMembershipCommand(TestCase):
-
-    def setUp(self):
-        # Créé un membre
-        self.username = MemberTestsUtils.get_random_username()
-        self.member = Member(first_name='Richard', last_name='Stallman',
-                             username=self.username)
-        self.member.save()
-
-
-    def tearDown(self):
-        # Supprime le membre
-        self.member.delete()
-        MembershipFee.objects.all().delete()
-
-    def create_membership_fee(self, end_date):
-        # Créé une cotisation passée se terminant dans un mois
-        membershipfee = MembershipFee(member=self.member, amount=20,
-                                      start_date=end_date + relativedelta(years=-1),
-                                      end_date=end_date)
-        membershipfee.save()
-
-    def create_membership_fee(self, end_date):
-        # Créé une cotisation se terminant à la date indiquée
-        membershipfee = MembershipFee(member=self.member, amount=20,
-                                      start_date=end_date + relativedelta(years=-1),
-                                      end_date=end_date)
-        membershipfee.save()
-        return membershipfee
-
-    def do_test_email_sent(self, expected_emails = 1, reset_date_last_call = True):
-        # Vide la outbox
-        mail.outbox = []
-        # Call command
-        management.call_command('call_for_membership_fees', stdout=StringIO())
-        # Test
-        self.assertEqual(len(mail.outbox), expected_emails)
-        # Comme on utilise le même membre, on reset la date de dernier envoi
-        if reset_date_last_call:
-            self.member.date_last_call_for_membership_fees_email = None
-            self.member.save()
-
-    def do_test_for_a_end_date(self, end_date, expected_emails=1, reset_date_last_call = True):
-        # Supprimer toutes les cotisations (au cas ou)
-        MembershipFee.objects.all().delete()
-        # Créé la cotisation
-        membershipfee = self.create_membership_fee(end_date)
-        self.do_test_email_sent(expected_emails, reset_date_last_call)
-        membershipfee.delete()
-
-    def test_call_email_sent_at_expected_dates(self):
-        # 1 mois avant la fin, à la fin et chaque mois après la fin pendant 3 mois
-        self.do_test_for_a_end_date(date.today() + relativedelta(months=+1))
-        self.do_test_for_a_end_date(date.today())
-        self.do_test_for_a_end_date(date.today() + relativedelta(months=-1))
-        self.do_test_for_a_end_date(date.today() + relativedelta(months=-2))
-        self.do_test_for_a_end_date(date.today() + relativedelta(months=-3))
-
-    def test_call_email_not_sent_if_active_membership_fee(self):
-        # Créé une cotisation se terminant dans un mois
-        membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1))
-        # Un mail devrait être envoyé (ne pas vider date_last_call_for_membership_fees_email)
-        self.do_test_email_sent(1, False)
-        # Créé une cotisation enchainant et se terminant dans un an
-        membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1, years=+1))
-        # Pas de mail envoyé
-        self.do_test_email_sent(0)
-
-    def test_date_last_call_for_membership_fees_email(self):
-        # Créé une cotisation se terminant dans un mois
-        membershipfee = self.create_membership_fee(date.today() + relativedelta(months=+1))
-        # Un mail envoyé (ne pas vider date_last_call_for_membership_fees_email)
-        self.do_test_email_sent(1, False)
-        # Tente un deuxième envoi, qui devrait être à 0
-        self.do_test_email_sent(0)
-
-
 class MemberTestsUtils(object):
 class MemberTestsUtils(object):
 
 
     @staticmethod
     @staticmethod
@@ -510,21 +371,3 @@ class TestValidators(TestCase):
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             chatroom_url_validator('http://#faimaison@irc.geeknode.org')
             chatroom_url_validator('http://#faimaison@irc.geeknode.org')
 
 
-
-class MembershipFeeTests(TestCase):
-    def test_mandatory_start_date(self):
-        member = Member(first_name='foo', last_name='foo', password='foo', email='foo')
-        member.save()
-
-        # If there is no start_date clean_fields() should raise an
-        # error but not clean().
-        membershipfee = MembershipFee(member=member)
-        self.assertRaises(ValidationError, membershipfee.clean_fields)
-        self.assertIsNone(membershipfee.clean())
-
-        # If there is a start_date, everything is fine.
-        membershipfee = MembershipFee(member=member, start_date=date.today())
-        self.assertIsNone(membershipfee.clean_fields())
-        self.assertIsNone(membershipfee.clean())
-
-        member.delete()

+ 2 - 1
coin/members/views.py

@@ -5,6 +5,7 @@ from django.shortcuts import render
 from django.contrib.auth.decorators import login_required
 from django.contrib.auth.decorators import login_required
 from django.conf import settings
 from django.conf import settings
 from forms import PersonMemberChangeForm, OrganizationMemberChangeForm
 from forms import PersonMemberChangeForm, OrganizationMemberChangeForm
+from coin.billing.models import Bill
 
 
 @login_required
 @login_required
 def index(request):
 def index(request):
@@ -52,7 +53,7 @@ def subscriptions(request):
 @login_required
 @login_required
 def invoices(request):
 def invoices(request):
     balance  = request.user.balance
     balance  = request.user.balance
-    invoices = request.user.invoices.filter(validated=True).order_by('-date')
+    invoices = Bill.get_member_validated_bills(request.user)
     payments = request.user.payments.filter().order_by('-date')
     payments = request.user.payments.filter().order_by('-date')
 
 
     return render(request, 'members/invoices.html',
     return render(request, 'members/invoices.html',

+ 19 - 0
housing/migrations/0002_auto_20180414_2250.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('housing', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='housingconfiguration',
+            name='activated',
+            field=models.BooleanField(default=True, verbose_name='activ\xe9'),
+        ),
+    ]

+ 19 - 0
vpn/migrations/0004_auto_20180414_2250.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('vpn', '0003_merge'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='vpnconfiguration',
+            name='activated',
+            field=models.BooleanField(default=True, verbose_name='activ\xe9'),
+        ),
+    ]

+ 19 - 0
vps/migrations/0004_auto_20180414_2250.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('vps', '0003_auto_20170803_0411'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='vpsconfiguration',
+            name='activated',
+            field=models.BooleanField(default=True, verbose_name='activ\xe9'),
+        ),
+    ]