Browse Source

Merge with master

ljf 6 years ago
parent
commit
0659ffdf68

+ 2 - 0
README.md

@@ -346,6 +346,8 @@ List of available settings in your `settings_local.py` file.
 - `MEMBER_DEFAULT_COTISATION` : Default membership fee, if you have a more complex membership fees policy, you could overwrite templates
 - `MEMBER_DEFAULT_COTISATION` : Default membership fee, if you have a more complex membership fees policy, you could overwrite templates
 - `PAYMENT_DELAY`: Payment delay in days for issued invoices ( default is 30 days which is the default in french law)
 - `PAYMENT_DELAY`: Payment delay in days for issued invoices ( default is 30 days which is the default in french law)
 - `MEMBER_CAN_EDIT_PROFILE`: Allow members to edit their profiles
 - `MEMBER_CAN_EDIT_PROFILE`: Allow members to edit their profiles
+- `HANDLE_BALANCE`: Allows to handle money balances for members (False default)
+- `INVOICES_INCLUDE_CONFIG_COMMENTS`: Add comment related to a subscription configuration when generating invoices
 
 
 Accounting logs
 Accounting logs
 ---------------
 ---------------

+ 13 - 1
coin/billing/admin.py

@@ -6,6 +6,7 @@ from django.contrib import messages
 from django.http import HttpResponseRedirect
 from django.http import HttpResponseRedirect
 from django.conf.urls import url
 from django.conf.urls import url
 from django.contrib.admin.utils import flatten_fieldsets
 from django.contrib.admin.utils import flatten_fieldsets
+from django import forms
 
 
 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
@@ -14,11 +15,22 @@ from django.core.urlresolvers import reverse
 import autocomplete_light
 import autocomplete_light
 
 
 
 
+class InvoiceDetailInlineForm(forms.ModelForm):
+    class Meta:
+        model = InvoiceDetail
+        fields = (
+            'label', 'amount', 'quantity', 'tax',
+            'offersubscription', 'period_from', 'period_to'
+        )
+        widgets = {'quantity': forms.NumberInput(attrs={'step': 1})}
+
+
 class InvoiceDetailInline(LimitedAdminInlineMixin, admin.StackedInline):
 class InvoiceDetailInline(LimitedAdminInlineMixin, admin.StackedInline):
     model = InvoiceDetail
     model = InvoiceDetail
     extra = 0
     extra = 0
     fields = (('label', 'amount', 'quantity', 'tax'),
     fields = (('label', 'amount', 'quantity', 'tax'),
               ('offersubscription', 'period_from', 'period_to'))
               ('offersubscription', 'period_from', 'period_to'))
+    form = InvoiceDetailInlineForm
 
 
     def get_filters(self, obj):
     def get_filters(self, obj):
         """
         """
@@ -217,7 +229,7 @@ class PaymentAdmin(admin.ModelAdmin):
     def get_readonly_fields(self, request, obj=None):
     def get_readonly_fields(self, request, obj=None):
 
 
         # If payment already started to be allocated or already have a member
         # If payment already started to be allocated or already have a member
-        if obj and (obj.amount_already_allocated != 0 or obj.member != None):
+        if obj and (obj.amount_already_allocated() != 0 or obj.member != None):
             # All fields are readonly
             # All fields are readonly
             return flatten_fieldsets(self.declared_fieldsets)
             return flatten_fieldsets(self.declared_fieldsets)
         else:
         else:

+ 3 - 2
coin/billing/create_subscriptions_invoices.py

@@ -11,7 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist
 from coin.offers.models import Offer, OfferSubscription
 from coin.offers.models import Offer, OfferSubscription
 from coin.members.models import Member
 from coin.members.models import Member
 from coin.billing.models import Invoice, InvoiceDetail
 from coin.billing.models import Invoice, InvoiceDetail
-
+from django.conf import settings
 
 
 def create_all_members_invoices_for_a_period(date=None):
 def create_all_members_invoices_for_a_period(date=None):
     """
     """
@@ -140,7 +140,8 @@ def create_member_invoice_for_a_period(member, date):
                 # à la facture
                 # à la facture
                 label = offer.name
                 label = offer.name
                 try:
                 try:
-                    if (offer_subscription.configuration.comment):
+                    if settings.INVOICES_INCLUDE_CONFIG_COMMENTS and \
+                    (offer_subscription.configuration.comment):
                         label += " (%s)" % offer_subscription.configuration.comment
                         label += " (%s)" % offer_subscription.configuration.comment
                 except ObjectDoesNotExist:
                 except ObjectDoesNotExist:
                     pass
                     pass

+ 28 - 11
coin/billing/management/commands/import_payments_from_csv.py

@@ -27,6 +27,8 @@ import logging
 import os
 import os
 import re
 import re
 
 
+import unidecode
+
 # Django specific imports
 # Django specific imports
 from argparse import RawTextHelpFormatter
 from argparse import RawTextHelpFormatter
 from django.core.management.base import BaseCommand, CommandError
 from django.core.management.base import BaseCommand, CommandError
@@ -45,7 +47,7 @@ DATE_FORMAT="%d/%m/%Y"
 ID_REGEX=r"(?i)(\b|_)ID[\s\-\_\/]*(\d+)(\b|_)"
 ID_REGEX=r"(?i)(\b|_)ID[\s\-\_\/]*(\d+)(\b|_)"
 # If the label of the payment contains one of these, the payment won't be
 # If the label of the payment contains one of these, the payment won't be
 # matched to a member when importing it.
 # matched to a member when importing it.
-KEYWORDS_TO_NOTMATCH=[ "DON", "MECENAT", "REM CHQ" ]
+KEYWORDS_TO_NOTMATCH=[ "REM CHQ" ]
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
 
 
@@ -76,9 +78,10 @@ class Command(BaseCommand):
     def handle(self, *args, **options):
     def handle(self, *args, **options):
 
 
         assert options["filename"] != ""
         assert options["filename"] != ""
-
         if not os.path.isfile(options["filename"]):
         if not os.path.isfile(options["filename"]):
             raise CommandError("This file does not exists.")
             raise CommandError("This file does not exists.")
+        os.system("iconv -f ISO-8859-1 -t UTF-8 %s > %s.utf8.csv" % (options["filename"], options["filename"]))
+        options["filename"] = options["filename"] + '.utf8.csv'
 
 
         payments = self.convert_csv_to_dicts(self.clean_csv(self.load_csv(options["filename"])))
         payments = self.convert_csv_to_dicts(self.clean_csv(self.load_csv(options["filename"])))
 
 
@@ -189,13 +192,14 @@ class Command(BaseCommand):
 
 
     def try_to_match_payment_with_members(self, payments):
     def try_to_match_payment_with_members(self, payments):
 
 
-        members = Member.objects.filter(status="member")
+        #members = Member.objects.filter(status="member")
+        members = Member.objects.all()
 
 
         idregex = re.compile(ID_REGEX)
         idregex = re.compile(ID_REGEX)
 
 
         for payment in payments:
         for payment in payments:
 
 
-            payment_label = payment["label"]
+            payment_label = payment["label"].upper()
 
 
             # First, attempt to match the member ID
             # First, attempt to match the member ID
             idmatches = idregex.findall(payment_label)
             idmatches = idregex.findall(payment_label)
@@ -211,11 +215,14 @@ class Command(BaseCommand):
             # Second, attempt to find the username
             # Second, attempt to find the username
             usernamematch = None
             usernamematch = None
             for member in members:
             for member in members:
-                matches = re.compile(r"(?i)(\b|_)"+re.escape(member.username)+r"(\b|_)") \
+                username = self.flatten(member.username)
+                matches = re.compile(r"(?i)(\b|_)"+re.escape(username)+r"(\b|_)") \
                             .findall(payment_label)
                             .findall(payment_label)
+
                 # If not found, try next
                 # If not found, try next
                 if len(matches) == 0:
                 if len(matches) == 0:
                     continue
                     continue
+
                 # If we already had a match, abort the whole search because we
                 # If we already had a match, abort the whole search because we
                 # have multiple usernames matched !
                 # have multiple usernames matched !
                 if usernamematch != None:
                 if usernamematch != None:
@@ -236,23 +243,31 @@ class Command(BaseCommand):
                 if member.last_name == "":
                 if member.last_name == "":
                     continue
                     continue
 
 
-                matches = re.compile(r"(?i)(\b|_)"+re.escape(str(member.last_name))+r"(\b|_)") \
+                # "Flatten" accents in the last name... (probably the CSV
+                # don't contain 'special' chars like accents
+                member_last_name = self.flatten(member.last_name)
+
+                matches = re.compile(r"(?i)(\b|_)"+re.escape(member_last_name)+r"(\b|_)") \
                             .findall(payment_label)
                             .findall(payment_label)
+
                 # If not found, try next
                 # If not found, try next
                 if len(matches) == 0:
                 if len(matches) == 0:
                     continue
                     continue
+
                 # If this familyname was matched several time, abort the whole search
                 # If this familyname was matched several time, abort the whole search
-                if len(matches) > 1:
-                    familynamematch = None
-                    break
+                #if len(matches) > 1:
+                #    print("Several matches ! Aborting !")
+                #    familynamematch = None
+                #    break
+
                 # If we already had a match, abort the whole search because we
                 # If we already had a match, abort the whole search because we
                 # have multiple familynames matched !
                 # have multiple familynames matched !
                 if familynamematch != None:
                 if familynamematch != None:
                     familynamematch = None
                     familynamematch = None
                     break
                     break
 
 
-                familynamematch = str(member.last_name)
-                usernamematch = str(member.username)
+                familynamematch = member_last_name
+                usernamematch = member.username
 
 
             if familynamematch != None:
             if familynamematch != None:
                 payment["member_matched"] = usernamematch
                 payment["member_matched"] = usernamematch
@@ -336,3 +351,5 @@ class Command(BaseCommand):
                                              date=new_payment["date"],
                                              date=new_payment["date"],
                                              member=member)
                                              member=member)
 
 
+    def flatten(self, some_string):
+        return unidecode.unidecode(some_string).upper()

+ 57 - 42
coin/billing/models.py

@@ -87,7 +87,10 @@ class InvoiceNumber:
         :rtype: dict
         :rtype: dict
         """
         """
 
 
-        return {'{}__month'.format(field_name): date.month}
+        return {
+            '{}__month'.format(field_name): date.month,
+            '{}__year'.format(field_name): date.year
+        }
 
 
 
 
 class InvoiceQuerySet(models.QuerySet):
 class InvoiceQuerySet(models.QuerySet):
@@ -221,9 +224,11 @@ class Invoice(models.Model):
         self.save()
         self.save()
         self.generate_pdf()
         self.generate_pdf()
 
 
-        accounting_log.info("Draft invoice %s validated as invoice %s. "
-                            "(Total amount : %f ; Member : %s)"
-                            % (old_number, self.number, self.amount(), self.member))
+        accounting_log.info(
+            "Draft invoice {} validated as invoice {}. ".format(
+                old_number, self.number) +
+            "(Total amount : {} ; Member : {})".format(
+                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)
@@ -238,7 +243,8 @@ class Invoice(models.Model):
         return reverse('billing:invoice', args=[self.number])
         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 '#{} {:0.2f}€ {}'.format(
+            self.number, self.amount(), self.date_due)
 
 
     def reminder_needed(self):
     def reminder_needed(self):
 
 
@@ -273,8 +279,9 @@ class Invoice(models.Model):
         if not self.reminder_needed():
         if not self.reminder_needed():
             return False
             return False
 
 
-        accounting_log.info("Sending reminder email to %s to pay invoice %s"
-                           % (str(self.member), str(self.number)))
+        accounting_log.info(
+            "Sending reminder email to {} to pay invoice {}".format(
+                self.member, str(self.number)))
 
 
         isp_info = ISPInfo.objects.first()
         isp_info = ISPInfo.objects.first()
         kwargs = {}
         kwargs = {}
@@ -413,9 +420,9 @@ class Payment(models.Model):
         amount_to_pay  = invoice.amount_remaining_to_pay()
         amount_to_pay  = invoice.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"
-                            % (float(amount_to_allocate), str(self.date),
-                               invoice.number))
+        accounting_log.info(
+            "Allocating {} from payment {} to invoice {}".format(
+                amount_to_allocate, self.date, invoice.number))
 
 
         PaymentAllocation.objects.create(invoice=invoice,
         PaymentAllocation.objects.create(invoice=invoice,
                                          payment=self,
                                          payment=self,
@@ -423,8 +430,9 @@ class Payment(models.Model):
 
 
         # Close invoice if relevant
         # Close invoice if relevant
         if (invoice.amount_remaining_to_pay() <= 0) and (invoice.status == "open"):
         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)
+            accounting_log.info(
+                "Invoice {} has been paid and is now closed".format(
+                    invoice.number))
             invoice.status = "closed"
             invoice.status = "closed"
 
 
         invoice.save()
         invoice.save()
@@ -432,11 +440,11 @@ class Payment(models.Model):
 
 
     def __unicode__(self):
     def __unicode__(self):
         if self.member is not None:
         if self.member is not None:
-            return 'Paiment de %0.2f€ le %s par %s' \
-                    % (self.amount, str(self.date), self.member)
+            return 'Paiment de {:0.2f}€ le {} par {}'.format(
+                self.amount, self.date, self.member)
         else:
         else:
-            return 'Paiment de %0.2f€ le %s' \
-                    % (self.amount, str(self.date))
+            return 'Paiment de {:0.2f}€ le {}'.format(
+                self.amount, self.date)
 
 
     class Meta:
     class Meta:
         verbose_name = 'paiement'
         verbose_name = 'paiement'
@@ -480,11 +488,12 @@ def update_accounting_for_member(member):
     Met à jour le status des factures, des paiements et le solde du compte
     Met à jour le status des factures, des paiements et le solde du compte
     d'un utilisateur
     d'un utilisateur
     """
     """
+    if not settings.HANDLE_BALANCE:
+        return
 
 
-    accounting_log.info("Updating accounting for member %s ..."
-                        % str(member))
-    accounting_log.info("Member %s current balance is %f ..."
-                        % (str(member), float(member.balance)))
+    accounting_log.info("Updating accounting for member {} ...".format(member))
+    accounting_log.info(
+        "Member {} current balance is {} ...".format(member, member.balance))
 
 
     reconcile_invoices_and_payments(member)
     reconcile_invoices_and_payments(member)
 
 
@@ -495,8 +504,8 @@ def update_accounting_for_member(member):
                                      this_member_payments)
                                      this_member_payments)
     member.save()
     member.save()
 
 
-    accounting_log.info("Member %s new balance is %f"
-                        % (str(member),  float(member.balance)))
+    accounting_log.info("Member {} new balance is {:f}".format(
+        member, member.balance))
 
 
 
 
 def reconcile_invoices_and_payments(member):
 def reconcile_invoices_and_payments(member):
@@ -508,18 +517,19 @@ def reconcile_invoices_and_payments(member):
     active_payments, active_invoices = get_active_payment_and_invoices(member)
     active_payments, active_invoices = get_active_payment_and_invoices(member)
 
 
     if active_payments == []:
     if active_payments == []:
-        accounting_log.info("(No active payment for %s. No invoice/payment "
-                            "reconciliation needed.)."
-                            % str(member))
+        accounting_log.info(
+            "(No active payment for {}.".format(member)
+            + " No invoice/payment reconciliation needed.).")
         return
         return
     elif active_invoices == []:
     elif active_invoices == []:
-        accounting_log.info("(No active invoice for %s. No invoice/payment "
-                            "reconciliation needed.)."
-                            % str(member))
+        accounting_log.info(
+            "(No active invoice for {}. No invoice/payment ".format(member) +
+            "reconciliation needed.).")
         return
         return
 
 
-    accounting_log.info("Initiating reconciliation between "
-                        "invoice and payments for %s" % str(member))
+    accounting_log.info(
+        "Initiating reconciliation between invoice and payments for {}".format(
+            member))
 
 
     while active_payments != [] and active_invoices != []:
     while active_payments != [] and active_invoices != []:
 
 
@@ -531,8 +541,9 @@ def reconcile_invoices_and_payments(member):
             # Assert that the invoice is still 'active'
             # Assert that the invoice is still 'active'
             assert p.invoice in active_invoices
             assert p.invoice in active_invoices
             i = p.invoice
             i = p.invoice
-            accounting_log.info("Payment is to be allocated specifically to " \
-                                "invoice %s" % str(i.number))
+            accounting_log.info(
+                "Payment is to be allocated specifically to invoice {}".format(
+                    i.number))
         else:
         else:
             i = active_invoices[0]
             i = active_invoices[0]
 
 
@@ -588,15 +599,19 @@ def payment_changed(sender, instance, created, **kwargs):
 def invoice_changed(sender, instance, created, **kwargs):
 def invoice_changed(sender, instance, created, **kwargs):
 
 
     if created:
     if created:
-        accounting_log.info("Creating draft invoice %s (Member: %s)."
-                            % ('DRAFT-{}'.format(instance.pk), instance.member))
+        accounting_log.info(
+            "Creating draft invoice DRAFT-{} (Member: {}).".format(
+                instance.pk, instance.member))
     else:
     else:
         if not instance.validated:
         if not instance.validated:
-            accounting_log.info("Updating draft invoice %s (Member: %s)."
-                    % (instance.number, instance.member))
+            accounting_log.info(
+                "Updating draft invoice DRAFT-{} (Member: {}).".format(
+                    instance.number, instance.member))
         else:
         else:
-            accounting_log.info("Updating invoice %s (Member: %s, Total amount: %s, Amount paid: %s)."
-                    % (instance.number, instance.member, instance.amount(), instance.amount_paid() ))
+            accounting_log.info(
+                "Updating invoice {} (Member: {}, Total amount: {}, Amount paid: {}).".format(
+                    instance.number, instance.member,
+                    instance.amount(), instance.amount_paid()))
 
 
 @receiver(post_delete, sender=PaymentAllocation)
 @receiver(post_delete, sender=PaymentAllocation)
 def paymentallocation_deleted(sender, instance, **kwargs):
 def paymentallocation_deleted(sender, instance, **kwargs):
@@ -605,7 +620,7 @@ def paymentallocation_deleted(sender, instance, **kwargs):
 
 
     # Reopen invoice if relevant
     # Reopen invoice if relevant
     if (invoice.amount_remaining_to_pay() > 0) and (invoice.status == "closed"):
     if (invoice.amount_remaining_to_pay() > 0) and (invoice.status == "closed"):
-        accounting_log.info("Reopening invoice %s ..." % invoice.number)
+        accounting_log.info("Reopening invoice {} ...".format(invoice.number))
         invoice.status = "open"
         invoice.status = "open"
         invoice.save()
         invoice.save()
 
 
@@ -613,9 +628,9 @@ def paymentallocation_deleted(sender, instance, **kwargs):
 @receiver(post_delete, sender=Payment)
 @receiver(post_delete, sender=Payment)
 def payment_deleted(sender, instance, **kwargs):
 def payment_deleted(sender, instance, **kwargs):
 
 
-    accounting_log.info("Deleted payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
-                        % (instance.pk, instance.date, instance.member,
-                            instance.amount, instance.label))
+    accounting_log.info(
+        "Deleted payment {} (Date: {}, Member: {}, Amount: {}, Label: {}).".format(
+            instance.pk, instance.date, instance.member, instance.amount, instance.label))
 
 
     member = instance.member
     member = instance.member
 
 

+ 15 - 2
coin/billing/tests.py

@@ -5,7 +5,7 @@ import datetime
 from decimal import Decimal
 from decimal import Decimal
 
 
 from django.conf import settings
 from django.conf import settings
-from django.test import TestCase, Client
+from django.test import TestCase, Client, override_settings
 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
@@ -15,6 +15,7 @@ from coin.billing.create_subscriptions_invoices import create_member_invoice_for
 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
 
 
 
 
+@override_settings(HANDLE_BALANCE=True)
 class BillingInvoiceCreationTests(TestCase):
 class BillingInvoiceCreationTests(TestCase):
 
 
     def setUp(self):
     def setUp(self):
@@ -316,7 +317,7 @@ class InvoiceQuerySetTests(TestCase):
     @freeze_time('2016-01-01')
     @freeze_time('2016-01-01')
     def test_number_workflow(self):
     def test_number_workflow(self):
         iv = Invoice.objects.create()
         iv = Invoice.objects.create()
-        self.assertEqual(iv.number, 'DRAFT-1')
+        self.assertEqual(iv.number, 'DRAFT-{}'.format(iv.pk))
         iv.validate()
         iv.validate()
         self.assertRegexpMatches(iv.number, r'2016-01-000001$')
         self.assertRegexpMatches(iv.number, r'2016-01-000001$')
 
 
@@ -328,6 +329,18 @@ class InvoiceQuerySetTests(TestCase):
             Invoice.objects.get_next_invoice_number(datetime.date(2016,1,1)),
             Invoice.objects.get_next_invoice_number(datetime.date(2016,1,1)),
             '2016-01-000002')
             '2016-01-000002')
 
 
+    def test_get_right_year_invoice_number(self):
+        with freeze_time('2016-01-01'):
+            Invoice.objects.create(date=datetime.date(2016, 1, 1)).validate()
+        with freeze_time('2017-01-01'):
+            Invoice.objects.create(date=datetime.date(2017, 1, 1)).validate()
+        with freeze_time('2018-01-01'):
+            Invoice.objects.create(date=datetime.date(2018, 1, 1)).validate()
+
+        self.assertEqual(
+            Invoice.objects.get_next_invoice_number(datetime.date(2017, 1, 1)),
+            '2017-01-000002')
+
     def test_bill_date_is_validation_date(self):
     def test_bill_date_is_validation_date(self):
         bill = Invoice.objects.create(date=datetime.date(2016,1,1))
         bill = Invoice.objects.create(date=datetime.date(2016,1,1))
         self.assertEqual(bill.date, datetime.date(2016,1,1))
         self.assertEqual(bill.date, datetime.date(2016,1,1))

+ 10 - 8
coin/members/admin.py

@@ -9,6 +9,7 @@ from django.contrib.auth.models import Group, Permission
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.http import HttpResponseRedirect
 from django.http import HttpResponseRedirect
 from django.conf.urls import url
 from django.conf.urls import url
+from django.conf import settings
 from django.db.models.query import QuerySet
 from django.db.models.query import QuerySet
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 from django.utils.html import format_html
 from django.utils.html import format_html
@@ -109,14 +110,13 @@ class MemberAdmin(UserAdmin):
 
 
         # if obj is null then it is a creation, otherwise it is a modification
         # if obj is null then it is a creation, otherwise it is a modification
         if obj:
         if obj:
-            return (
+            fieldsets = (
                 ('Adhérent', {'fields': (
                 ('Adhérent', {'fields': (
                     ('status', 'date_joined', 'resign_date'),
                     ('status', 'date_joined', 'resign_date'),
                     'type',
                     'type',
                     ('first_name', 'last_name', 'nickname'),
                     ('first_name', 'last_name', 'nickname'),
                     'organization_name',
                     'organization_name',
-                    'comments',
-                    'balance' # XXX we shouldn't need this, the default value should be used
+                    'comments'
                 )}),
                 )}),
                 coord_fieldset,
                 coord_fieldset,
                 auth_fieldset,
                 auth_fieldset,
@@ -124,18 +124,20 @@ class MemberAdmin(UserAdmin):
                 (None, {'fields': ('date_last_call_for_membership_fees_email',)})
                 (None, {'fields': ('date_last_call_for_membership_fees_email',)})
             )
             )
         else:
         else:
-            return (
+            fieldsets = (
                 ('Adhérent', {'fields': (
                 ('Adhérent', {'fields': (
                     ('status', 'date_joined'),
                     ('status', 'date_joined'),
                     'type',
                     'type',
                     ('first_name', 'last_name', 'nickname'),
                     ('first_name', 'last_name', 'nickname'),
                     'organization_name',
                     'organization_name',
-                    'comments',
-                    'balance')}),
+                    'comments')}),
                 coord_fieldset,
                 coord_fieldset,
                 auth_fieldset,
                 auth_fieldset,
                 perm_fieldset
                 perm_fieldset
             )
             )
+        if settings.HANDLE_BALANCE:
+            fieldsets[0][1]['fields'] += ('balance',)
+        return fieldsets
 
 
     radio_fields = {"type": admin.HORIZONTAL}
     radio_fields = {"type": admin.HORIZONTAL}
 
 
@@ -241,13 +243,13 @@ class MemberAdmin(UserAdmin):
                                      email=member.email,
                                      email=member.email,
                                      last_call_date=member.date_last_call_for_membership_fees_email))
                                      last_call_date=member.date_last_call_for_membership_fees_email))
 
 
-        if queryset.count() == 1 and cpt_success == 1:
+        if cpt_success == 1:
             member = queryset.first()
             member = queryset.first()
             messages.success(request,
             messages.success(request,
                              "Le courriel de relance de cotisation a été "
                              "Le courriel de relance de cotisation a été "
                              "envoyé à {member} ({email})"\
                              "envoyé à {member} ({email})"\
                              .format(member=member, email=member.email))
                              .format(member=member, email=member.email))
-        elif cpt_success>1:
+        elif cpt_success > 1:
             messages.success(request,
             messages.success(request,
                              "Le courriel de relance de cotisation a été "
                              "Le courriel de relance de cotisation a été "
                              "envoyé à {cpt} membres"\
                              "envoyé à {cpt} membres"\

+ 3 - 1
coin/members/templates/members/invoices.html

@@ -2,7 +2,9 @@
 
 
 {% block content %}
 {% block content %}
 
 
-<h2>Balance : {{ balance|floatformat }} €</h2>
+{% if handle_balance %}
+	<h2>Balance : {{ balance|floatformat }} €</h2>
+{% endif %}
 
 
 <h2>Mes factures</h2>
 <h2>Mes factures</h2>
 
 

+ 2 - 2
coin/members/templates/members/registration/password_reset_done.html

@@ -7,9 +7,9 @@
     <div class="large-12 columns">
     <div class="large-12 columns">
         <h2>Vérifiez votre boîte email</h2>
         <h2>Vérifiez votre boîte email</h2>
 
 
-        <p>{% trans "We've emailed you instructions for setting your password. You should be receiving them shortly." %}</p>
+        <p>Nous vous avons envoyé des instructions par mail pour réinitialiser votre mot de passe. Vous devriez les recevoir très bientôt.</p>
 
 
-        <p>{% trans "If you don't receive an email, please make sure you've entered the address you registered with, and check your spam folder." %}</p>
+        <p>Si vous ne recevez pas d'email, vérifiez que vous avez bien entré la bonne adresse, et vérifiez dans les spams.</p>
     </div>
     </div>
 </div>
 </div>
 
 

+ 1 - 0
coin/members/views.py

@@ -58,6 +58,7 @@ def invoices(request):
 
 
     return render_to_response('members/invoices.html',
     return render_to_response('members/invoices.html',
                               {'balance' : balance, 
                               {'balance' : balance, 
+                               'handle_balance' : settings.HANDLE_BALANCE, 
                                'invoices': invoices, 
                                'invoices': invoices, 
                                'payments': payments},
                                'payments': payments},
                               context_instance=RequestContext(request))
                               context_instance=RequestContext(request))

+ 6 - 0
coin/settings_base.py

@@ -283,3 +283,9 @@ ACCOUNT_ACTIVATION_DAYS = 7
 
 
 # Member can edit their own data
 # Member can edit their own data
 MEMBER_CAN_EDIT_PROFILE = False
 MEMBER_CAN_EDIT_PROFILE = False
+
+# Allows to deactivate displays and calculations of balances.
+HANDLE_BALANCE = False
+
+# Add subscription comments in invoice items
+INVOICES_INCLUDE_CONFIG_COMMENTS = True

+ 6 - 4
hardware_provisioning/admin.py

@@ -6,7 +6,6 @@ from __future__ import unicode_literals
 from django.contrib import admin
 from django.contrib import admin
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.forms import ModelChoiceField
 from django.forms import ModelChoiceField
-from django.utils import timezone
 import autocomplete_light
 import autocomplete_light
 
 
 from .models import ItemType, Item, Loan, Storage
 from .models import ItemType, Item, Loan, Storage
@@ -43,6 +42,7 @@ class AvailabilityFilter(admin.SimpleListFilter):
         return [
         return [
             ('available', 'Disponible'),
             ('available', 'Disponible'),
             ('borrowed', 'Emprunté'),
             ('borrowed', 'Emprunté'),
+            ('deployed', 'Déployé'),
         ]
         ]
 
 
     def queryset(self, request, queryset):
     def queryset(self, request, queryset):
@@ -50,6 +50,8 @@ class AvailabilityFilter(admin.SimpleListFilter):
             return queryset.available()
             return queryset.available()
         elif self.value() == 'borrowed':
         elif self.value() == 'borrowed':
             return queryset.borrowed()
             return queryset.borrowed()
+        elif self.value() == 'deployed':
+            return queryset.deployed()
         else:
         else:
             return queryset
             return queryset
 
 
@@ -57,10 +59,10 @@ class AvailabilityFilter(admin.SimpleListFilter):
 @admin.register(Item)
 @admin.register(Item)
 class ItemAdmin(admin.ModelAdmin):
 class ItemAdmin(admin.ModelAdmin):
     list_display = (
     list_display = (
-        'designation', 'type', 'mac_address', 'serial', 'owner',
-        'buy_date', 'deployed', 'is_available')
+        'designation', 'mac_address', 'serial', 'owner',
+        'buy_date', 'deployed', 'is_available', 'storage')
     list_filter = (
     list_filter = (
-        AvailabilityFilter, 'type__name', 'storage',
+        AvailabilityFilter, 'type', 'storage',
         'buy_date', OwnerFilter)
         'buy_date', OwnerFilter)
     search_fields = (
     search_fields = (
         'designation', 'mac_address', 'serial',
         'designation', 'mac_address', 'serial',

+ 14 - 1
hardware_provisioning/models.py

@@ -2,6 +2,7 @@
 
 
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 from django.db import models
 from django.db import models
+from django.db.models import Q
 from django.conf import settings
 from django.conf import settings
 from django.utils import timezone
 from django.utils import timezone
 
 
@@ -24,11 +25,22 @@ class ItemQuerySet(models.QuerySet):
         return Loan.objects.running().values_list('item', flat=True)
         return Loan.objects.running().values_list('item', flat=True)
 
 
     def available(self):
     def available(self):
-        return self.exclude(pk__in=self._get_borrowed_pks())
+        return self.exclude(
+            pk__in=self._get_borrowed_pks()).exclude(deployed=True)
 
 
     def borrowed(self):
     def borrowed(self):
         return self.filter(pk__in=self._get_borrowed_pks())
         return self.filter(pk__in=self._get_borrowed_pks())
 
 
+    def deployed(self):
+        return self.filter(deployed=True)
+
+    def unavailable(self):
+        """ deployed or borrowed
+        """
+        return self.filter(
+            Q(pk__in=self._get_borrowed_pks()) |
+            Q(deployed=True))
+
 
 
 class Item(models.Model):
 class Item(models.Model):
     type = models.ForeignKey(ItemType, verbose_name='type de matériel',
     type = models.ForeignKey(ItemType, verbose_name='type de matériel',
@@ -100,6 +112,7 @@ class Item(models.Model):
 
 
     class Meta:
     class Meta:
         verbose_name = 'objet'
         verbose_name = 'objet'
+        ordering = ['designation', 'mac_address', 'serial']
 
 
     def give_back(self, storage=None):
     def give_back(self, storage=None):
         self.storage = storage
         self.storage = storage

+ 46 - 11
hardware_provisioning/tests.py

@@ -14,20 +14,30 @@ def localize(naive_dt):
         return timezone.make_aware(naive_dt, timezone.get_current_timezone())
         return timezone.make_aware(naive_dt, timezone.get_current_timezone())
 
 
 
 
-class HardwareLoaningTestCase(TestCase):
+class HardwareModelsFactoryMixin:
+    def get_item_type(self, **kwargs):
+        params = {'name': 'Foos'}
+        params.update(**kwargs)
+        item_type, _ = ItemType.objects.get_or_create(**kwargs)
+        return item_type
+
+    def get_item(self, **kwargs):
+        params = {
+            'type': self.get_item_type(),
+            'designation': 'Test item',
+        }
+        params.update(**kwargs)
+        item, _ = Item.objects.get_or_create(**params)
+        return item
+
+
+class HardwareLoaningTestCase(HardwareModelsFactoryMixin, TestCase):
     def setUp(self):
     def setUp(self):
-        self.member = Member.objects.create(
-            first_name='John',
-            last_name='Doe',
-            username='jdoe')
-        self.item_type = ItemType.objects.create(name='Foos')
-        self.item = Item.objects.create(
-            type=self.item_type,
-            designation='Bar Wheel',
-            buy_date=date(2012,12,5))
+        self.member = Member.objects.create(username='jdoe')
+        self.item = self.get_item()
 
 
     def test_running_(self):
     def test_running_(self):
-        loan_start_date = localize(datetime(2011,1,14,12,0,0))
+        loan_start_date = localize(datetime(2011, 1, 14, 12, 0, 0))
         loan = Loan.objects.create(
         loan = Loan.objects.create(
             item=self.item, user=self.member,
             item=self.item, user=self.member,
             loan_date=loan_start_date)
             loan_date=loan_start_date)
@@ -37,3 +47,28 @@ class HardwareLoaningTestCase(TestCase):
         loan.item.give_back()
         loan.item.give_back()
         self.assertEqual(Loan.objects.running().count(), 0)
         self.assertEqual(Loan.objects.running().count(), 0)
         self.assertEqual(Loan.objects.finished().count(), 1)
         self.assertEqual(Loan.objects.finished().count(), 1)
+
+
+class ItemTestCase(HardwareModelsFactoryMixin, TestCase):
+    def setUp(self):
+        self.member = Member.objects.create(username='jdoe')
+
+        self.free_item = self.get_item(designation='free')
+        self.deployed_item = self.get_item(
+            designation='deployed', deployed=True)
+        self.borrowed_item = self.get_item(designation='borrowed')
+
+    def test_queryset_methods(self):
+        self.assertEqual(Item.objects.borrowed().count(), 0)
+        self.assertEqual(Item.objects.deployed().count(), 1)
+        self.assertEqual(Item.objects.available().count(), 2)
+        self.assertEqual(Item.objects.unavailable().count(), 1)
+
+        Loan.objects.create(
+            item=self.borrowed_item, user=self.member,
+            loan_date=localize(datetime(2011, 1, 14, 12, 0, 0)))
+
+        self.assertEqual(Item.objects.borrowed().count(), 1)
+        self.assertEqual(Item.objects.deployed().count(), 1)
+        self.assertEqual(Item.objects.available().count(), 1)
+        self.assertEqual(Item.objects.unavailable().count(), 2)

+ 3 - 1
requirements.txt

@@ -1,5 +1,5 @@
 Django>=1.8.17,<1.9
 Django>=1.8.17,<1.9
-psycopg2==2.5.2
+psycopg2==2.5.4
 python-ldap==2.4.15
 python-ldap==2.4.15
 wsgiref==0.1.2
 wsgiref==0.1.2
 python-dateutil==2.2
 python-dateutil==2.2
@@ -17,3 +17,5 @@ six==1.10.0
 WeasyPrint==0.31
 WeasyPrint==0.31
 freezegun==0.3.8
 freezegun==0.3.8
 django-registration==2.2
 django-registration==2.2
+pytz>=2018.5
+unidecode>=1.0,<1.1