ljf il y a 6 ans
Parent
commit
72871e3643
44 fichiers modifiés avec 2256 ajouts et 225 suppressions
  1. 29 1
      README.md
  2. 88 7
      coin/billing/admin.py
  3. 3 2
      coin/billing/create_subscriptions_invoices.py
  4. 355 0
      coin/billing/management/commands/import_payments_from_csv.py
  5. 37 0
      coin/billing/management/commands/send_reminders_for_unpaid_bills.py
  6. 19 0
      coin/billing/migrations/0008_auto_20170802_2021.py
  7. 54 0
      coin/billing/migrations/0009_new_billing_system_schema.py
  8. 72 0
      coin/billing/migrations/0010_new_billing_system_data.py
  9. 366 30
      coin/billing/models.py
  10. 75 8
      coin/billing/tests.py
  11. 151 0
      coin/isp_database/migrations/0014_auto_20170802_2021.py
  12. 59 32
      coin/isp_database/models.py
  13. 109 51
      coin/members/admin.py
  14. 10 7
      coin/members/autocomplete_light_registry.py
  15. 50 4
      coin/members/forms.py
  16. 2 1
      coin/members/management/commands/call_for_membership_fees.py
  17. 46 3
      coin/members/management/commands/members_email.py
  18. 19 0
      coin/members/migrations/0014_member_balance.py
  19. 19 0
      coin/members/migrations/0014_member_send_membership_fees_email.py
  20. 19 0
      coin/members/migrations/0015_auto_20170824_2308.py
  21. 15 0
      coin/members/migrations/0016_merge.py
  22. 25 0
      coin/members/migrations/0016_rowlevelpermission.py
  23. 15 0
      coin/members/migrations/0017_merge.py
  24. 73 2
      coin/members/models.py
  25. 18 8
      coin/members/templates/members/detail.html
  26. 30 0
      coin/members/templates/members/invoices.html
  27. 2 2
      coin/members/templates/members/registration/password_reset_done.html
  28. 29 6
      coin/members/views.py
  29. 25 1
      coin/offers/admin.py
  30. 0 19
      coin/offers/management/commands/subscribers_email.py
  31. 18 0
      coin/offers/models.py
  32. 15 0
      coin/settings_base.py
  33. 154 0
      doc/user/permissions.md
  34. 52 5
      hardware_provisioning/admin.py
  35. 1 1
      hardware_provisioning/app.py
  36. 31 0
      hardware_provisioning/migrations/0015_auto_20170802_1701.py
  37. 35 0
      hardware_provisioning/migrations/0016_auto_20170802_2021.py
  38. 19 0
      hardware_provisioning/migrations/0017_item_deployed.py
  39. 37 16
      hardware_provisioning/models.py
  40. 46 11
      hardware_provisioning/tests.py
  41. 4 2
      requirements.txt
  42. 5 5
      vpn/admin.py
  43. 23 0
      vpn/migrations/0002_auto_20170802_2021.py
  44. 2 1
      vpn/models.py

+ 29 - 1
README.md

@@ -187,7 +187,8 @@ Some useful administration commands are available via `manage.py`.
 per line.  This may be useful to automatically feed a mailing list software.
 Note that membership is based on the `status` field of users, not on
 membership fees.  That is, even if a member has forgot to renew his or her
-membership fee, his or her address will still show up in this list.
+membership fee, his or her address will still show up in this list. More
+filters are available, see the command's help for more details.
 
 `python manage.py charge_subscriptions`: generate invoices (including a
 PDF version) for each subscriber.  You probably want to run this command
@@ -201,6 +202,10 @@ You should run this command in a cron job every day.
 `python manage.py offer_subscriptions_count`: Returns subscription count grouped
 by offer type.
 
+`python manage.py import_payments_from_csv`: Import a CSV from a bank and match
+payments with services and/or members. At the moment, this is quite specific to
+ARN workflow
+
 Configuration
 =============
 
@@ -336,6 +341,29 @@ List of available settings in your `settings_local.py` file.
 - `MEMBER_MEMBERSHIP_INFO_URL`: Link to a page with information on how to become a member or pay the membership fee
 - `SUBSCRIPTION_REFERENCE`: Pattern used to display a unique reference for any subscription. Helpful for bank wire transfer identification
 - `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
+- `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
+---------------
+
+To log 'accounting-related operations' (creation/update of invoice, payment
+and member balance) to a specific file, add the following to settings_local.py :
+
+```
+from settings_base import *
+LOGGING["formatters"]["verbose"] = {'format': "%(asctime)s - %(name)s - %(levelname)s - %(message)s"}
+LOGGING["handlers"]["coin_accounting"] = {
+    'level':'INFO',
+    'class':'logging.handlers.RotatingFileHandler',
+    'formatter': 'verbose',
+    'filename': '/var/log/coin/accounting.log',
+    'maxBytes': 1024*1024*15, # 15MB
+    'backupCount': 10,
+}
+LOGGING["loggers"]["coin.billing"]["handlers"] = [ 'coin_accounting' ]
+```
 
 More information
 ================

+ 88 - 7
coin/billing/admin.py

@@ -5,20 +5,32 @@ from django.contrib import admin
 from django.contrib import messages
 from django.http import HttpResponseRedirect
 from django.conf.urls import url
-from django.contrib.admin.util import flatten_fieldsets
+from django.contrib.admin.utils import flatten_fieldsets
+from django import forms
 
 from coin.filtering_queryset import LimitedAdminInlineMixin
-from coin.billing.models import Invoice, InvoiceDetail, Payment
+from coin.billing.models import Invoice, InvoiceDetail, Payment, PaymentAllocation
 from coin.billing.utils import get_invoice_from_id_or_number
 from django.core.urlresolvers import reverse
 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):
     model = InvoiceDetail
     extra = 0
     fields = (('label', 'amount', 'quantity', 'tax'),
               ('offersubscription', 'period_from', 'period_to'))
+    form = InvoiceDetailInlineForm
 
     def get_filters(self, obj):
         """
@@ -64,14 +76,36 @@ class InvoiceDetailInlineReadOnly(admin.StackedInline):
         return result
 
 
-class PaymentInline(admin.StackedInline):
+class PaymentAllocatedReadOnly(admin.TabularInline):
+    model = PaymentAllocation
+    extra = 0
+    fields = ("payment", "amount")
+    readonly_fields = ("payment", "amount")
+    verbose_name = None
+    verbose_name_plural = "Paiement alloués"
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        return False
+
+
+class PaymentInlineAdd(admin.StackedInline):
     model = Payment
     extra = 0
     fields = (('date', 'payment_mean', 'amount'),)
+    can_delete = False
+
+    verbose_name_plural = "Ajouter des paiements"
+
+    def has_change_permission(self, request):
+        return False
 
 
 class InvoiceAdmin(admin.ModelAdmin):
-    list_display = ('number', 'date', 'status', 'amount', 'member', 'validated')
+    list_display = ('number', 'date', 'status', 'amount', 'member',
+                    'validated')
     list_display_links = ('number', 'date')
     fields = (('number', 'date', 'status'),
               ('date_due'),
@@ -111,7 +145,10 @@ class InvoiceAdmin(admin.ModelAdmin):
             else:
                 inlines = [InvoiceDetailInline]
 
-            inlines += [PaymentInline]
+            if obj.validated:
+                inlines += [PaymentAllocatedReadOnly]
+                if obj.status == "open":
+                    inlines += [PaymentInlineAdd]
 
         for inline_class in inlines:
             inline = inline_class(self.model, self.admin_site)
@@ -144,11 +181,16 @@ class InvoiceAdmin(admin.ModelAdmin):
         Vue appelée lorsque l'admin souhaite valider une facture et
         générer son pdf
         """
+
         # TODO : Add better perm here
         if request.user.is_superuser:
             invoice = get_invoice_from_id_or_number(id)
-            invoice.validate()
-            messages.success(request, 'La facture a été validée.')
+            if invoice.amount() == 0:
+                messages.error(request, 'Une facture validée ne peut pas avoir'
+                                        ' un total de 0€.')
+            else:
+                invoice.validate()
+                messages.success(request, 'La facture a été validée.')
         else:
             messages.error(
                 request, 'Vous n\'avez pas l\'autorisation de valider '
@@ -158,4 +200,43 @@ class InvoiceAdmin(admin.ModelAdmin):
                                             args=(id,)))
 
 
+class PaymentAllocationInlineReadOnly(admin.TabularInline):
+    model = PaymentAllocation
+    extra = 0
+    fields = ("invoice", "amount")
+    readonly_fields = ("invoice", "amount")
+    verbose_name = None
+    verbose_name_plural = "Alloué à"
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        return False
+
+
+class PaymentAdmin(admin.ModelAdmin):
+
+    list_display = ('__unicode__', 'member', 'payment_mean', 'amount', 'date',
+                    'amount_already_allocated', 'label')
+    list_display_links = ()
+    fields = (('member'),
+              ('amount', 'payment_mean', 'date', 'label'),
+              ('amount_already_allocated'))
+    readonly_fields = ('amount_already_allocated', 'label')
+    form = autocomplete_light.modelform_factory(Payment, fields='__all__')
+
+    def get_readonly_fields(self, request, obj=None):
+
+        # If payment already started to be allocated or already have a member
+        if obj and (obj.amount_already_allocated() != 0 or obj.member != None):
+            # All fields are readonly
+            return flatten_fieldsets(self.declared_fieldsets)
+        else:
+            return self.readonly_fields
+
+    def get_inline_instances(self, request, obj=None):
+        return [PaymentAllocationInlineReadOnly(self.model, self.admin_site)]
+
 admin.site.register(Invoice, InvoiceAdmin)
+admin.site.register(Payment, PaymentAdmin)

+ 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.members.models import Member
 from coin.billing.models import Invoice, InvoiceDetail
-
+from django.conf import settings
 
 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
                 label = offer.name
                 try:
-                    if (offer_subscription.configuration.comment):
+                    if settings.INVOICES_INCLUDE_CONFIG_COMMENTS and \
+                    (offer_subscription.configuration.comment):
                         label += " (%s)" % offer_subscription.configuration.comment
                 except ObjectDoesNotExist:
                     pass

+ 355 - 0
coin/billing/management/commands/import_payments_from_csv.py

@@ -0,0 +1,355 @@
+# -*- coding: utf-8 -*-
+"""
+Import payments from a CSV file from a bank.  The payments will automatically be
+parsed, and there'll be an attempt to automatically match payments with members.
+
+The matching is performed using the label of the payment.
+- First, try to find a string such as 'ID-42' where 42 is the member's ID
+- Second (if no ID found), try to find a member username (with no ambiguity with
+  respect to other usernames)
+- Third (if no username found), try to find a member family name (with no
+  ambiguity with respect to other family name)
+
+This script will check if a payment has already been registered with same
+properies (date, label, price) to avoid creating duplicate payments inside coin.
+
+By default, only a dry-run is perfomed to let you see what will happen ! You
+should run this command with --commit if you agree with the dry-run.
+"""
+
+from __future__ import unicode_literals
+
+# Standard python libs
+import csv
+import datetime
+import json
+import logging
+import os
+import re
+
+import unidecode
+
+# Django specific imports
+from argparse import RawTextHelpFormatter
+from django.core.management.base import BaseCommand, CommandError
+
+# Coin specific imports
+from coin.members.models import Member
+from coin.billing.models import Payment
+
+# Parser / import / matcher configuration
+
+# The CSV delimiter
+DELIMITER=str(';')
+# The date format in the CSV
+DATE_FORMAT="%d/%m/%Y"
+# The default regex used to match the label of a payment with a member ID
+ID_REGEX=r"(?i)(\b|_)ID[\s\-\_\/]*(\d+)(\b|_)"
+# If the label of the payment contains one of these, the payment won't be
+# matched to a member when importing it.
+KEYWORDS_TO_NOTMATCH=[ "REM CHQ" ]
+
+class Command(BaseCommand):
+
+    help = __doc__
+
+    def create_parser(self, *args, **kwargs):
+        parser = super(Command, self).create_parser(*args, **kwargs)
+        parser.formatter_class = RawTextHelpFormatter
+        return parser
+
+    def add_arguments(self, parser):
+
+        parser.add_argument(
+            'filename',
+            type=str,
+            help="The CSV filename to be parsed"
+        )
+
+        parser.add_argument(
+            '--commit',
+            action='store_true',
+            dest='commit',
+            default=False,
+            help='Agree with the proposed change and commit them'
+        )
+
+
+    def handle(self, *args, **options):
+
+        assert options["filename"] != ""
+        if not os.path.isfile(options["filename"]):
+            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.try_to_match_payment_with_members(payments)
+        new_payments = self.filter_already_known_payments(payments)
+        new_payments = self.unmatch_payment_with_keywords(new_payments)
+
+        number_of_already_known_payments = len(payments)-len(new_payments)
+        number_of_new_payments = len(new_payments)
+
+        if (number_of_new_payments > 0) :
+            print("======================================================")
+            print("   > New payments found")
+            print(json.dumps(new_payments, indent=4, separators=(',', ': ')))
+        print("======================================================")
+        print("Number of already known payments found : " + str(number_of_already_known_payments))
+        print("Number of new payments found           : " + str(number_of_new_payments))
+        print("Number of new payments matched         : " + str(len([p for p in new_payments if     p["member_matched"]])))
+        print("Number of payments not matched         : " + str(len([p for p in new_payments if not p["member_matched"]])))
+        print("======================================================")
+
+        if number_of_new_payments == 0:
+            print("Nothing to do, everything looks up to date !")
+            return
+
+        if not options["commit"]:
+            print("Please carefully review the matches, then if everything \n" \
+                  "looks alright, use --commit to register these new payments.")
+        else:
+            self.add_new_payments(new_payments)
+
+
+    def is_date(self, text):
+        try:
+            datetime.datetime.strptime(text, DATE_FORMAT)
+            return True
+        except ValueError:
+            return False
+
+
+    def is_money_amount(self, text):
+        try:
+            float(text.replace(",","."))
+            return True
+        except ValueError:
+            return False
+
+
+    def load_csv(self, filename):
+        with open(filename, "r") as f:
+            return list(csv.reader(f, delimiter=DELIMITER))
+
+
+    def clean_csv(self, data):
+
+        output = []
+
+        for i, row in enumerate(data):
+
+            for j in range(len(row)):
+                row[j] = row[j].decode('utf-8')
+
+            if len(row) < 4:
+                continue
+
+            if not self.is_date(row[0]):
+                logging.warning("Ignoring the following row (bad format for date in the first column) :")
+                logging.warning(str(row))
+                continue
+
+            if self.is_money_amount(row[2]):
+                logging.warning("Ignoring row %s (not a payment)" % str(i))
+                logging.warning(str(row))
+                continue
+
+            if not self.is_money_amount(row[3]):
+                logging.warning("Ignoring the following row (bad format for money amount in colun three) :")
+                logging.warning(str(row))
+                continue
+
+            # Clean the date
+            row[0] = datetime.datetime.strptime(row[0], DATE_FORMAT).strftime("%Y-%m-%d")
+
+            # Clean the label ...
+            row[4] = row[4].replace('\r', ' ')
+            row[4] = row[4].replace('\n', ' ')
+
+            output.append(row)
+
+        return output
+
+
+    def convert_csv_to_dicts(self, data):
+
+        output = []
+
+        for row in data:
+            payment = {}
+
+            payment["date"] = row[0]
+            payment["label"] = row[4]
+            payment["amount"] = float(row[3].replace(",","."))
+
+            output.append(payment)
+
+        return output
+
+
+    def try_to_match_payment_with_members(self, payments):
+
+        #members = Member.objects.filter(status="member")
+        members = Member.objects.all()
+
+        idregex = re.compile(ID_REGEX)
+
+        for payment in payments:
+
+            payment_label = payment["label"].upper()
+
+            # First, attempt to match the member ID
+            idmatches = idregex.findall(payment_label)
+            if len(idmatches) == 1:
+                i = int(idmatches[0][1])
+                member_matches = [ member.username for member in members if member.pk==i ]
+                if len(member_matches) == 1:
+                    payment["member_matched"] = member_matches[0]
+                    #print("Matched by ID to "+member_matches[0])
+                    continue
+
+
+            # Second, attempt to find the username
+            usernamematch = None
+            for member in members:
+                username = self.flatten(member.username)
+                matches = re.compile(r"(?i)(\b|_)"+re.escape(username)+r"(\b|_)") \
+                            .findall(payment_label)
+
+                # If not found, try next
+                if len(matches) == 0:
+                    continue
+
+                # If we already had a match, abort the whole search because we
+                # have multiple usernames matched !
+                if usernamematch != None:
+                    usernamematch = None
+                    break
+
+                usernamematch = member.username
+
+            if usernamematch != None:
+                payment["member_matched"] = usernamematch
+                #print("Matched by username to "+usernamematch)
+                continue
+
+
+            # Third, attempt to match by family name
+            familynamematch = None
+            for member in members:
+                if member.last_name == "":
+                    continue
+
+                # "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)
+
+                # If not found, try next
+                if len(matches) == 0:
+                    continue
+
+                # If this familyname was matched several time, abort the whole search
+                #if len(matches) > 1:
+                #    print("Several matches ! Aborting !")
+                #    familynamematch = None
+                #    break
+
+                # If we already had a match, abort the whole search because we
+                # have multiple familynames matched !
+                if familynamematch != None:
+                    familynamematch = None
+                    break
+
+                familynamematch = member_last_name
+                usernamematch = member.username
+
+            if familynamematch != None:
+                payment["member_matched"] = usernamematch
+                #print("Matched by familyname to "+familynamematch)
+                continue
+
+            #print("Could not match")
+            payment["member_matched"] = None
+
+        return payments
+
+
+    def unmatch_payment_with_keywords(self, payments):
+
+        matchers = {}
+        for keyword in KEYWORDS_TO_NOTMATCH:
+            matchers[keyword] = re.compile(r"(?i)(\b|_|-)"+re.escape(keyword)+r"(\b|_|-)")
+
+        for i, payment in enumerate(payments):
+
+            # If no match found, don't filter anyway
+            if payment["member_matched"] == None:
+                continue
+
+            for keyword, matcher in matchers.items():
+                matches = matcher.findall(payment["label"])
+
+                # If not found, try next
+                if len(matches) == 0:
+                    continue
+
+                print("Ignoring possible match for payment '%s' because " \
+                      "it contains the keyword %s"                        \
+                      % (payment["label"], keyword))
+                payments[i]["member_matched"] = None
+
+                break
+
+        return payments
+
+    def filter_already_known_payments(self, payments):
+
+        new_payments = []
+
+        known_payments = Payment.objects.all()
+
+        for payment in payments:
+
+            found_match = False
+            for known_payment in known_payments:
+
+                if  (str(known_payment.date) == payment["date"].encode('utf-8')) \
+                and (known_payment.label == payment["label"]) \
+                and (float(known_payment.amount) == float(payment["amount"])):
+                    found_match = True
+                    break
+
+            if not found_match:
+                new_payments.append(payment)
+
+        return new_payments
+
+
+    def add_new_payments(self, new_payments):
+
+        for new_payment in new_payments:
+
+            # Get the member if there's a member matched
+            member = None
+            if new_payment["member_matched"]:
+                member = Member.objects.filter(username=new_payment["member_matched"])
+                assert len(member) == 1
+                member = member[0]
+
+            print("Adding new payment : ")
+            print(new_payment)
+
+            # Create the payment
+            payment = Payment.objects.create(amount=float(new_payment["amount"]),
+                                             label=new_payment["label"],
+                                             date=new_payment["date"],
+                                             member=member)
+
+    def flatten(self, some_string):
+        return unidecode.unidecode(some_string).upper()

+ 37 - 0
coin/billing/management/commands/send_reminders_for_unpaid_bills.py

@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+# Standard python libs
+import logging
+
+# Django specific imports
+from argparse import RawTextHelpFormatter
+from django.core.management.base import BaseCommand, CommandError
+
+# Coin specific imports
+from coin.billing.models import Invoice
+
+
+class Command(BaseCommand):
+
+    help = """
+Send a reminder to members for invoices which are due and not paid since a few
+weeks.
+"""
+
+    def create_parser(self, *args, **kwargs):
+        parser = super(Command, self).create_parser(*args, **kwargs)
+        parser.formatter_class = RawTextHelpFormatter
+        return parser
+
+    def handle(self, *args, **options):
+
+        invoices = Invoice.objects.filter(status="open")
+
+        for invoice in invoices:
+
+            if not invoice.reminder_needed():
+                continue
+
+            invoice.send_reminder(auto=True)
+

+ 19 - 0
coin/billing/migrations/0008_auto_20170802_2021.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', '0007_auto_20170801_1530'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoice',
+            name='status',
+            field=models.CharField(default='open', max_length=50, verbose_name='statut', choices=[('open', '\xc0 payer'), ('closed', 'R\xe9gl\xe9e'), ('trouble', 'Litige')]),
+        ),
+    ]

+ 54 - 0
coin/billing/migrations/0009_new_billing_system_schema.py

@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('billing', '0008_auto_20170802_2021'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PaymentAllocation',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('amount', models.DecimalField(null=True, verbose_name='montant', max_digits=5, decimal_places=2)),
+            ],
+        ),
+        migrations.AddField(
+            model_name='invoice',
+            name='date_last_reminder_email',
+            field=models.DateTimeField(null=True, verbose_name='Date du dernier email de relance envoy\xe9', blank=True),
+        ),
+        migrations.AddField(
+            model_name='payment',
+            name='label',
+            field=models.CharField(default='', max_length=500, null=True, verbose_name='libell\xe9', blank=True),
+        ),
+        migrations.AddField(
+            model_name='payment',
+            name='member',
+            field=models.ForeignKey(related_name='payments', on_delete=django.db.models.deletion.SET_NULL, default=None, blank=True, to=settings.AUTH_USER_MODEL, null=True, verbose_name='membre'),
+        ),
+        migrations.AlterField(
+            model_name='payment',
+            name='invoice',
+            field=models.ForeignKey(related_name='payments', verbose_name='facture associ\xe9e', blank=True, to='billing.Invoice', null=True),
+        ),
+        migrations.AddField(
+            model_name='paymentallocation',
+            name='invoice',
+            field=models.ForeignKey(related_name='allocations', verbose_name='facture associ\xe9e', to='billing.Invoice'),
+        ),
+        migrations.AddField(
+            model_name='paymentallocation',
+            name='payment',
+            field=models.ForeignKey(related_name='allocations', verbose_name='facture associ\xe9e', to='billing.Payment'),
+        ),
+    ]

+ 72 - 0
coin/billing/migrations/0010_new_billing_system_data.py

@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import sys
+
+from django.db import migrations
+
+from coin.members.models import Member
+from coin.billing.models import Invoice, InvoiceDetail, Payment
+
+
+def check_current_state(apps, schema_editor):
+
+    for invoice in Invoice.objects.all():
+
+        invoice_name = invoice.__unicode__()
+
+        related_payments = invoice.payments.all()
+
+        total_related_payments = sum([p.amount for p in related_payments])
+
+        if total_related_payments > invoice.amount:
+            error = "For invoice, current sum of payment is higher than total of invoice. Please fix this before running this migration" % invoice_name
+            raise AssertionError(error.encode('utf-8'))
+
+        if total_related_payments != 0 and not invoice.validated:
+            error = "Invoice %s is not validated but already has allocated payments. Please remove them before running this migration" % invoice_name
+            raise AssertionError(error.encode('utf-8'))
+
+
+def forwards(apps, schema_editor):
+
+    # Create allocation for all payment to their respective invoice
+    for payment in Payment.objects.all():
+        payment.member = payment.invoice.member
+        payment.allocate_to_invoice(payment.invoice)
+
+    # Update balance for all members
+    for member in Member.objects.all():
+
+        this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+        this_member_payments = [p for p in member.payments.order_by("date")]
+
+        member.balance = compute_balance(this_member_invoices,
+                                         this_member_payments)
+        member.save()
+
+
+def compute_balance(invoices, payments):
+
+    active_payments = [p for p in payments if p.amount_not_allocated()    > 0]
+    active_invoices = [i for i in invoices if i.amount_remaining_to_pay() > 0]
+
+    s = 0
+    s -= sum([i.amount_remaining_to_pay() for i in active_invoices])
+    s += sum([p.amount_not_allocated()    for p in active_payments])
+
+    return s
+
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0009_new_billing_system_schema'),
+        ('members', '0016_merge'),
+    ]
+
+    operations = [
+        migrations.RunPython(check_current_state),
+        migrations.RunPython(forwards),
+    ]

+ 366 - 30
coin/billing/models.py

@@ -2,25 +2,31 @@
 from __future__ import unicode_literals
 
 import datetime
-import random
+import logging
 import uuid
-import os
 import re
 from decimal import Decimal
+from dateutil.relativedelta import relativedelta
 
 from django.conf import settings
 from django.db import models, transaction
-from django.db.models.signals import post_save
-from django.dispatch import receiver
+from django.utils import timezone
 from django.utils.encoding import python_2_unicode_compatible
-
+from django.dispatch import receiver
+from django.db.models.signals import post_save, post_delete
+from django.core.exceptions import ValidationError
+from django.core.urlresolvers import reverse
 
 from coin.offers.models import OfferSubscription
 from coin.members.models import Member
 from coin.html2pdf import render_as_pdf
 from coin.utils import private_files_storage, start_of_month, end_of_month, \
-                       disable_for_loaddata, postgresql_regexp
+                       postgresql_regexp, send_templated_email,             \
+                       disable_for_loaddata
 from coin.isp_database.context_processors import branding
+from coin.isp_database.models import ISPInfo
+
+accounting_log = logging.getLogger("coin.billing")
 
 
 def invoice_pdf_filename(instance, filename):
@@ -30,6 +36,7 @@ def invoice_pdf_filename(instance, filename):
                                       instance.number,
                                       uuid.uuid4())
 
+
 @python_2_unicode_compatible
 class InvoiceNumber:
     """ Logic and validation of invoice numbers
@@ -80,7 +87,10 @@ class InvoiceNumber:
         :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):
@@ -106,11 +116,12 @@ class InvoiceQuerySet(models.QuerySet):
         return self.filter(number__regex=postgresql_regexp(
             InvoiceNumber.RE_INVOICE_NUMBER))
 
+
 class Invoice(models.Model):
 
     INVOICES_STATUS_CHOICES = (
-        ('open', 'A payer'),
-        ('closed', 'Reglée'),
+        ('open', 'À payer'),
+        ('closed', 'Réglée'),
         ('trouble', 'Litige')
     )
 
@@ -139,6 +150,9 @@ class Invoice(models.Model):
                            null=True, blank=True,
                            verbose_name='PDF')
 
+    date_last_reminder_email = models.DateTimeField(null=True, blank=True,
+                        verbose_name="Date du dernier email de relance envoyé")
+
     def save(self, *args, **kwargs):
         # First save to get a PK
         super(Invoice, self).save(*args, **kwargs)
@@ -167,13 +181,9 @@ class Invoice(models.Model):
 
     def amount_paid(self):
         """
-        Calcul le montant payé de la facture en fonction des éléments
-        de paiements
+        Calcul le montant déjà payé à partir des allocations de paiements
         """
-        total = Decimal('0.0')
-        for payment in self.payments.all():
-            total += payment.amount
-        return total.quantize(Decimal('0.01'))
+        return sum([a.amount for a in self.allocations.all()])
     amount_paid.short_description = 'Montant payé'
 
     def amount_remaining_to_pay(self):
@@ -207,12 +217,22 @@ class Invoice(models.Model):
         self.date = datetime.date.today()
         if not self.date_due:
             self.date_due = self.date + datetime.timedelta(days=settings.PAYMENT_DELAY)
+        old_number = self.number
         self.number = Invoice.objects.get_next_invoice_number(self.date)
+
         self.validated = True
         self.save()
         self.generate_pdf()
-        
+
+        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()
+        if self.member is not None:
+            update_accounting_for_member(self.member)
+
 
     def pdf_exists(self):
         return (self.validated
@@ -220,17 +240,78 @@ class Invoice(models.Model):
                 and private_files_storage.exists(self.pdf.name))
 
     def get_absolute_url(self):
-        from django.core.urlresolvers import reverse
         return reverse('billing:invoice', args=[self.number])
 
     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):
+
+        # If there's no member, there's nobody to be reminded
+        if self.member is None:
+            return False
+
+        # If bill is close or not validated yet, nope
+        if self.status != 'open' or not self.validated:
+            return False
+
+        # If bill is not at least one month old, nope
+        if self.date_due >= timezone.now()+relativedelta(weeks=-4):
+            return False
+
+        # If a reminder has been recently sent, nope
+        if (self.date_last_reminder_email
+            and (self.date_last_reminder_email
+                 >= timezone.now() + relativedelta(weeks=-3))):
+            return False
+
+        return True
+
+    def send_reminder(self, auto=False):
+        """ Envoie un courrier pour rappeler à un abonné qu'une facture est
+        en attente de paiement
+
+        :param bill: id of the bill to remind
+        :param auto: is it an auto email? (changes slightly template content)
+        """
+
+        if not self.reminder_needed():
+            return False
+
+        accounting_log.info(
+            "Sending reminder email to {} to pay invoice {}".format(
+                self.member, str(self.number)))
+
+        isp_info = ISPInfo.objects.first()
+        kwargs = {}
+        # Il peut ne pas y avir d'ISPInfo, ou bien pas d'administrative_email
+        if isp_info and isp_info.administrative_email:
+            kwargs['from_email'] = isp_info.administrative_email
+
+        # Si le dernier courriel de relance a été envoyé il y a moins de trois
+        # semaines, n'envoi pas un nouveau courriel
+        send_templated_email(
+            to=self.member.email,
+            subject_template='billing/emails/reminder_for_unpaid_bill.txt',
+            body_template='billing/emails/reminder_for_unpaid_bill.html',
+            context={'member': self.member, 'branding': isp_info,
+                     'membership_info_url': settings.MEMBER_MEMBERSHIP_INFO_URL,
+                     'today': datetime.date.today,
+                     'auto_sent': auto},
+            **kwargs)
+
+        # Sauvegarde en base la date du dernier envoi de mail de relance
+        self.date_last_reminder_email = timezone.now()
+        self.save()
+        return True
 
     class Meta:
         verbose_name = 'facture'
 
     objects = InvoiceQuerySet().as_manager()
 
+
 class InvoiceDetail(models.Model):
 
     label = models.CharField(max_length=100)
@@ -281,6 +362,11 @@ class Payment(models.Model):
         ('other', 'Autre')
     )
 
+    member = models.ForeignKey(Member, null=True, blank=True, default=None,
+                               related_name='payments',
+                               verbose_name='membre',
+                               on_delete=models.SET_NULL)
+
     payment_mean = models.CharField(max_length=100, null=True,
                                     default='transfer',
                                     choices=PAYMENT_MEAN_CHOICES,
@@ -288,24 +374,274 @@ class Payment(models.Model):
     amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
                                  verbose_name='montant')
     date = models.DateField(default=datetime.date.today)
-    invoice = models.ForeignKey(Invoice, verbose_name='facture',
-                                related_name='payments')
+    invoice = models.ForeignKey(Invoice, verbose_name='facture associée', null=True,
+                                blank=True, related_name='payments')
+
+    label = models.CharField(max_length=500,
+                             null=True, blank=True, default="",
+                             verbose_name='libellé')
+
+    def save(self, *args, **kwargs):
+
+        # Only if no amount already allocated...
+        if self.amount_already_allocated() == 0:
+
+            # If there's a linked invoice and no member defined
+            if self.invoice and not self.member:
+                # Automatically set member to invoice's member
+                self.member = self.invoice.member
+
+        super(Payment, self).save(*args, **kwargs)
+
+
+    def clean(self):
+
+        # Only if no amount already alloca ted...
+        if self.amount_already_allocated() == 0:
+
+            # If there's a linked invoice and this payment would pay more than
+            # the remaining amount needed to pay the invoice...
+            if self.invoice and self.amount > self.invoice.amount_remaining_to_pay():
+                raise ValidationError("This payment would pay more than the invoice's remaining to pay")
+
+    def amount_already_allocated(self):
+        return sum([ a.amount for a in self.allocations.all() ])
+
+    def amount_not_allocated(self):
+        return self.amount - self.amount_already_allocated()
+
+    @transaction.atomic
+    def allocate_to_invoice(self, invoice):
+
+        # FIXME - Add asserts about remaining amount > 0, unpaid amount > 0,
+        # ...
+
+        amount_can_pay = self.amount_not_allocated()
+        amount_to_pay  = invoice.amount_remaining_to_pay()
+        amount_to_allocate = min(amount_can_pay, amount_to_pay)
+
+        accounting_log.info(
+            "Allocating {} from payment {} to invoice {}".format(
+                amount_to_allocate, self.date, invoice.number))
+
+        PaymentAllocation.objects.create(invoice=invoice,
+                                         payment=self,
+                                         amount=amount_to_allocate)
+
+        # Close invoice if relevant
+        if (invoice.amount_remaining_to_pay() <= 0) and (invoice.status == "open"):
+            accounting_log.info(
+                "Invoice {} has been paid and is now closed".format(
+                    invoice.number))
+            invoice.status = "closed"
+
+        invoice.save()
+        self.save()
 
     def __unicode__(self):
-        return 'Paiment de %0.2f€' % self.amount
+        if self.member is not None:
+            return 'Paiment de {:0.2f}€ le {} par {}'.format(
+                self.amount, self.date, self.member)
+        else:
+            return 'Paiment de {:0.2f}€ le {}'.format(
+                self.amount, self.date)
 
     class Meta:
         verbose_name = 'paiement'
 
 
-@receiver(post_save, sender=Payment)
-@disable_for_loaddata
-def set_invoice_as_paid_if_needed(sender, instance, **kwargs):
+# This corresponds to a (possibly partial) allocation of a given payment to
+# a given invoice.
+# E.g. consider an invoice I with total 15€ and a payment P with 10€.
+# There can be for example an allocation of 3.14€ from P to I.
+class PaymentAllocation(models.Model):
+
+    invoice = models.ForeignKey(Invoice, verbose_name='facture associée',
+                                null=False, blank=False,
+                                related_name='allocations')
+    payment = models.ForeignKey(Payment, verbose_name='facture associée',
+                                null=False, blank=False,
+                                related_name='allocations')
+    amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
+                                 verbose_name='montant')
+
+
+def get_active_payment_and_invoices(member):
+
+    # Fetch relevant and active payments / invoices
+    # 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_payments = [p for p in member.payments.order_by("date")]
+
+    # TODO / FIXME ^^^ maybe also consider only 'opened' invoices (i.e. not
+    # conflict / trouble invoices)
+
+    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]
+
+    return active_payments, active_invoices
+
+
+def update_accounting_for_member(member):
     """
-    Lorsqu'un paiement est enregistré, vérifie si la facture est alors
-    complétement payée. Dans ce cas elle passe en réglée
+    Met à jour le status des factures, des paiements et le solde du compte
+    d'un utilisateur
     """
-    if (instance.invoice.amount_paid() >= instance.invoice.amount() and
-            instance.invoice.status == 'open'):
-        instance.invoice.status = 'closed'
-        instance.invoice.save()
+    if not settings.HANDLE_BALANCE:
+        return
+
+    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)
+
+    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_payments = [p for p in member.payments.order_by("date")]
+
+    member.balance = compute_balance(this_member_invoices,
+                                     this_member_payments)
+    member.save()
+
+    accounting_log.info("Member {} new balance is {:f}".format(
+        member, member.balance))
+
+
+def reconcile_invoices_and_payments(member):
+    """
+    Rapproche des factures et des paiements qui sont actifs (paiement non alloué
+    ou factures non entièrement payées) automatiquement.
+    """
+
+    active_payments, active_invoices = get_active_payment_and_invoices(member)
+
+    if active_payments == []:
+        accounting_log.info(
+            "(No active payment for {}.".format(member)
+            + " No invoice/payment reconciliation needed.).")
+        return
+    elif active_invoices == []:
+        accounting_log.info(
+            "(No active invoice for {}. No invoice/payment ".format(member) +
+            "reconciliation needed.).")
+        return
+
+    accounting_log.info(
+        "Initiating reconciliation between invoice and payments for {}".format(
+            member))
+
+    while active_payments != [] and active_invoices != []:
+
+        # Only consider the oldest active payment and the oldest active invoice
+        p = active_payments[0]
+
+        # If this payment is to be allocated for a specific invoice...
+        if p.invoice:
+            # Assert that the invoice is still 'active'
+            assert p.invoice in active_invoices
+            i = p.invoice
+            accounting_log.info(
+                "Payment is to be allocated specifically to invoice {}".format(
+                    i.number))
+        else:
+            i = active_invoices[0]
+
+        # TODO : should add an assert that the ammount not allocated / remaining to
+        # pay is lower before and after calling the allocate_to_invoice
+
+        p.allocate_to_invoice(i)
+
+        active_payments, active_invoices = get_active_payment_and_invoices(member)
+
+    if active_payments == []:
+        accounting_log.info("No more active payment. Nothing to reconcile anymore.")
+    elif active_invoices == []:
+        accounting_log.info("No more active invoice. Nothing to reconcile anymore.")
+    return
+
+
+def compute_balance(invoices, payments):
+
+    active_payments = [p for p in payments if p.amount_not_allocated()    > 0]
+    active_invoices = [i for i in invoices if i.amount_remaining_to_pay() > 0]
+
+    s = 0
+    s -= sum([i.amount_remaining_to_pay() for i in active_invoices])
+    s += sum([p.amount_not_allocated()    for p in active_payments])
+
+    return s
+
+
+@receiver(post_save, sender=Payment)
+@disable_for_loaddata
+def payment_changed(sender, instance, created, **kwargs):
+
+    if created:
+        accounting_log.info("Adding payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
+                            % (instance.pk, instance.date, instance.member,
+                                instance.amount, instance.label))
+    else:
+        accounting_log.info("Updating payment %s (Date: %s, Member: %s, Amount: %s, Label: %s, Allocated: %s)."
+                            % (instance.pk, instance.date, instance.member,
+                                instance.amount, instance.label,
+                                instance.amount_already_allocated()))
+
+    # If this payment is related to a member, update the accounting for
+    # this member
+    if (created or instance.amount_not_allocated() != 0) \
+    and (instance.member is not None):
+        update_accounting_for_member(instance.member)
+
+
+@receiver(post_save, sender=Invoice)
+@disable_for_loaddata
+def invoice_changed(sender, instance, created, **kwargs):
+
+    if created:
+        accounting_log.info(
+            "Creating draft invoice DRAFT-{} (Member: {}).".format(
+                instance.pk, instance.member))
+    else:
+        if not instance.validated:
+            accounting_log.info(
+                "Updating draft invoice DRAFT-{} (Member: {}).".format(
+                    instance.number, instance.member))
+        else:
+            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)
+def paymentallocation_deleted(sender, instance, **kwargs):
+
+    invoice = instance.invoice
+
+    # Reopen invoice if relevant
+    if (invoice.amount_remaining_to_pay() > 0) and (invoice.status == "closed"):
+        accounting_log.info("Reopening invoice {} ...".format(invoice.number))
+        invoice.status = "open"
+        invoice.save()
+
+
+@receiver(post_delete, sender=Payment)
+def payment_deleted(sender, instance, **kwargs):
+
+    accounting_log.info(
+        "Deleted payment {} (Date: {}, Member: {}, Amount: {}, Label: {}).".format(
+            instance.pk, instance.date, instance.member, instance.amount, instance.label))
+
+    member = instance.member
+
+    if member is None:
+        return
+
+    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_payments = [p for p in member.payments.order_by("date")]
+
+    member.balance = compute_balance(this_member_invoices,
+                                     this_member_payments)
+    member.save()
+
+

+ 75 - 8
coin/billing/tests.py

@@ -5,16 +5,17 @@ import datetime
 from decimal import Decimal
 
 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 coin.members.tests import MemberTestsUtils
 from coin.members.models import Member, LdapUser
-from coin.billing.models import Invoice, InvoiceQuerySet
+from coin.billing.models import Invoice, InvoiceQuerySet, InvoiceDetail, Payment
 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_all_members_invoices_for_a_period
 
 
+@override_settings(HANDLE_BALANCE=True)
 class BillingInvoiceCreationTests(TestCase):
 
     def setUp(self):
@@ -114,7 +115,7 @@ class BillingInvoiceCreationTests(TestCase):
                          datetime.date(2014, 4, 1))
         self.assertEqual(invoice_test_2.details.first().period_to,
                          datetime.date(2014, 5, 31))
-                         
+
     def test_invoice_amount(self):
         invoice = Invoice(member=self.member)
         invoice.save()
@@ -132,7 +133,7 @@ class BillingInvoiceCreationTests(TestCase):
                                period_from=datetime.date(2014, 6, 1),
                                period_to=datetime.date(2014, 8, 31),
                                tax=10)
-        
+
         self.assertEqual(invoice.amount(), 111)
 
     def test_invoice_partial_payment(self):
@@ -145,10 +146,26 @@ class BillingInvoiceCreationTests(TestCase):
                                period_from=datetime.date(2014, 1, 1),
                                period_to=datetime.date(2014, 3, 31),
                                tax=0)
+        invoice.validate()
+        invoice.save()
+
         self.assertEqual(invoice.status, 'open')
-        invoice.payments.create(payment_mean='cash', amount=10)
+        p1 = Payment.objects.create(member=self.member,
+                                    invoice=invoice,
+                                    payment_mean='cash',
+                                    amount=10)
+        p1.save()
+
+        invoice = Invoice.objects.get(pk=invoice.pk)
         self.assertEqual(invoice.status, 'open')
-        invoice.payments.create(payment_mean='cash', amount=90)
+
+        p2 = Payment.objects.create(member=self.member,
+                                    invoice=invoice,
+                                    payment_mean='cash',
+                                    amount=90)
+        p2.save()
+
+        invoice = Invoice.objects.get(pk=invoice.pk)
         self.assertEqual(invoice.status, 'closed')
 
     def test_invoice_amount_before_tax(self):
@@ -168,7 +185,7 @@ class BillingInvoiceCreationTests(TestCase):
                                period_from=datetime.date(2014, 6, 1),
                                period_to=datetime.date(2014, 8, 31),
                                tax=10)
-        
+
         self.assertEqual(invoice.amount_before_tax(), 110)
 
     def test_non_billable_offer_isnt_charged(self):
@@ -300,7 +317,7 @@ class InvoiceQuerySetTests(TestCase):
     @freeze_time('2016-01-01')
     def test_number_workflow(self):
         iv = Invoice.objects.create()
-        self.assertEqual(iv.number, 'DRAFT-1')
+        self.assertEqual(iv.number, 'DRAFT-{}'.format(iv.pk))
         iv.validate()
         self.assertRegexpMatches(iv.number, r'2016-01-000001$')
 
@@ -312,6 +329,18 @@ class InvoiceQuerySetTests(TestCase):
             Invoice.objects.get_next_invoice_number(datetime.date(2016,1,1)),
             '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):
         bill = Invoice.objects.create(date=datetime.date(2016,1,1))
         self.assertEqual(bill.date, datetime.date(2016,1,1))
@@ -320,3 +349,41 @@ class InvoiceQuerySetTests(TestCase):
             bill.validate()
             self.assertEqual(bill.date, datetime.date(2017, 1, 1))
             self.assertEqual(bill.number, '2017-01-000001')
+
+
+class PaymentInvoiceAutoReconciliationTests(TestCase):
+
+    def test_accounting_update(self):
+
+        johndoe =  Member.objects.create(username=MemberTestsUtils.get_random_username(),
+                                         first_name="John",
+                                         last_name="Doe",
+                                         email="johndoe@yolo.test")
+        johndoe.set_password("trololo")
+
+        # First facture
+        invoice = Invoice.objects.create(number="1337",
+                                         member=johndoe)
+        InvoiceDetail.objects.create(label="superservice",
+                                     amount="15.0",
+                                     invoice=invoice)
+        invoice.validate()
+
+        # Second facture
+        invoice2 = Invoice.objects.create(number="42",
+                                         member=johndoe)
+        InvoiceDetail.objects.create(label="superservice",
+                                     amount="42",
+                                     invoice=invoice2)
+        invoice2.validate()
+
+        # Payment
+        payment = Payment.objects.create(amount=20,
+                                         member=johndoe)
+
+        invoice.delete()
+        invoice2.delete()
+        payment.delete()
+        johndoe.delete()
+
+

+ 151 - 0
coin/isp_database/migrations/0014_auto_20170802_2021.py

@@ -0,0 +1,151 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import multiselectfield.db.fields
+import django.core.validators
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('isp_database', '0013_merge'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='chatroom',
+            options={'verbose_name': 'Salon de discussions', 'verbose_name_plural': 'Salons de discussions'},
+        ),
+        migrations.AlterModelOptions(
+            name='coveredarea',
+            options={'verbose_name': 'Zone couverte', 'verbose_name_plural': 'Zones couvertes'},
+        ),
+        migrations.AlterModelOptions(
+            name='ispinfo',
+            options={'verbose_name': 'Information du FAI', 'verbose_name_plural': 'Informations du FAI'},
+        ),
+        migrations.AlterModelOptions(
+            name='otherwebsite',
+            options={'verbose_name': 'Autre site Internet', 'verbose_name_plural': 'Autres sites Internet'},
+        ),
+        migrations.AlterModelOptions(
+            name='registeredoffice',
+            options={'verbose_name': 'Si\xe8ge social', 'verbose_name_plural': 'Si\xe8ges sociaux'},
+        ),
+        migrations.AlterField(
+            model_name='coveredarea',
+            name='name',
+            field=models.CharField(max_length=512, verbose_name='Nom'),
+        ),
+        migrations.AlterField(
+            model_name='coveredarea',
+            name='technologies',
+            field=multiselectfield.db.fields.MultiSelectField(max_length=42, verbose_name='Technologie', choices=[('ftth', 'FTTH'), ('dsl', '*DSL'), ('wifi', 'WiFi'), ('vpn', 'VPN'), ('cube', 'Brique Internet')]),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='creationDate',
+            field=models.DateField(help_text='Date de cr\xe9ation de la structure l\xe9gale', null=True, verbose_name='Date de cr\xe9ation', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='description',
+            field=models.TextField(help_text='Description courte du projet', verbose_name='Description', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='email',
+            field=models.EmailField(help_text='Adresse courriel de contact', max_length=254, verbose_name='Courriel'),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='ffdnMemberSince',
+            field=models.DateField(help_text='Date \xe0 laquelle le FAI a rejoint la F\xe9d\xe9ration FDN', null=True, verbose_name='Membre de FFDN depuis', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='latitude',
+            field=models.FloatField(help_text='Coordonn\xe9es latitudinales du si\xe8ge', null=True, verbose_name='Latitude', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='logoURL',
+            field=models.URLField(help_text='Adresse HTTP(S) du logo du FAI', verbose_name='URL du logo', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='longitude',
+            field=models.FloatField(help_text='Coordonn\xe9es longitudinales du si\xe8ge', null=True, verbose_name='Longitude', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='mainMailingList',
+            field=models.EmailField(help_text='Principale liste de discussion publique', max_length=254, verbose_name='Liste de discussion principale', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='name',
+            field=models.CharField(help_text='Nom du FAI', max_length=512, verbose_name='Nom'),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='phone_number',
+            field=models.CharField(help_text='Num\xe9ro de t\xe9l\xe9phone de contact principal', max_length=25, verbose_name='Num\xe9ro de t\xe9l\xe9phone', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='progressStatus',
+            field=models.PositiveSmallIntegerField(blank=True, help_text="\xc9tat d'avancement du FAI", null=True, verbose_name="\xc9tat d'avancement", validators=[django.core.validators.MaxValueValidator(7)]),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='shortname',
+            field=models.CharField(help_text='Nom plus court', max_length=15, verbose_name='Abr\xe9viation', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='ispinfo',
+            name='website',
+            field=models.URLField(help_text='Adresse URL du site Internet', verbose_name='URL du site Internet', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='otherwebsite',
+            name='name',
+            field=models.CharField(max_length=512, verbose_name='Nom'),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='country_name',
+            field=models.CharField(max_length=512, verbose_name='Pays'),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='extended_address',
+            field=models.CharField(max_length=512, verbose_name='Adresse compl\xe9mentaire', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='locality',
+            field=models.CharField(max_length=512, verbose_name='Ville'),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='post_office_box',
+            field=models.CharField(max_length=512, verbose_name='Bo\xeete postale', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='postal_code',
+            field=models.CharField(max_length=512, verbose_name='Code postal', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='region',
+            field=models.CharField(max_length=512, verbose_name='R\xe9gion'),
+        ),
+        migrations.AlterField(
+            model_name='registeredoffice',
+            name='street_address',
+            field=models.CharField(max_length=512, verbose_name='Adresse', blank=True),
+        ),
+    ]

+ 59 - 32
coin/isp_database/models.py

@@ -53,48 +53,55 @@ class ISPInfo(SingleInstanceMixin, models.Model):
         return count_active_subscriptions()
 
     name = models.CharField(max_length=512,
-                            help_text="The ISP's name")
+                            verbose_name="Nom",
+                            help_text="Nom du FAI")
     # Length required by the spec
     shortname = models.CharField(max_length=15, blank=True,
-                                 help_text="Shorter name")
+                                 verbose_name="Abréviation",
+                                 help_text="Nom plus court")
     description = models.TextField(blank=True,
-                                   help_text="Short text describing the project")
+                                   verbose_name="Description",
+                                   help_text="Description courte du projet")
     logoURL = models.URLField(blank=True,
-                              verbose_name="logo URL",
-                              help_text="HTTP(S) URL of the ISP's logo")
+                              verbose_name="URL du logo",
+                              help_text="Adresse HTTP(S) du logo du FAI")
     website = models.URLField(blank=True,
-                              help_text='URL to the official website')
-    email = models.EmailField(help_text="Contact email address")
+                              verbose_name="URL du site Internet",
+                              help_text='Adresse URL du site Internet')
+    email = models.EmailField(verbose_name="Courriel",
+                              help_text="Adresse courriel de contact")
     mainMailingList = models.EmailField(blank=True,
-                                        verbose_name="main mailing list",
-                                        help_text="Main public mailing-list")
+                                        verbose_name="Liste de discussion principale",
+                                        help_text="Principale liste de discussion publique")
     phone_number = models.CharField(max_length=25, blank=True,
-                                    verbose_name="phone number",
-                                    help_text='Main contact phone number')
+                                    verbose_name="Numéro de téléphone",
+                                    help_text='Numéro de téléphone de contact principal')
     creationDate = models.DateField(blank=True, null=True,
-                                    verbose_name="creation date",
-                                     help_text="Date of creation for legal structure")
+                                    verbose_name="Date de création",
+                                    help_text="Date de création de la structure légale")
     ffdnMemberSince = models.DateField(blank=True, null=True,
-                                       verbose_name="FFDN member since",
-                                       help_text="Date at wich the ISP joined the Federation")
+                                       verbose_name="Membre de FFDN depuis",
+                                       help_text="Date à laquelle le FAI a rejoint la Fédération FDN")
     # TODO: choice field
     progressStatus = models.PositiveSmallIntegerField(
         validators=[MaxValueValidator(7)],
-        blank=True, null=True, verbose_name='progress status',
-        help_text="Progression status of the ISP")
+        blank=True, null=True, verbose_name="État d'avancement",
+        help_text="État d'avancement du FAI")
     # TODO: better model for coordinates
     latitude = models.FloatField(blank=True, null=True,
-        help_text="Coordinates of the registered office (latitude)")
+                                 verbose_name="Latitude",
+                                 help_text="Coordonnées latitudinales du siège")
     longitude = models.FloatField(blank=True, null=True,
-        help_text="Coordinates of the registered office (longitude)")
+                                  verbose_name="Longitude",
+                                  help_text="Coordonnées longitudinales du siège")
 
     # Uncomment this (and handle the necessary migrations) if you want to
     # manage one of the counters by hand.  Otherwise, they are computed
     # automatically, which is probably what you want.
-    #memberCount = models.PositiveIntegerField(help_text="Number of members",
+    #memberCount = models.PositiveIntegerField(help_text="Nombre de membres",
     #                                          default=0)
     #subscriberCount = models.PositiveIntegerField(
-    #    help_text="Number of subscribers to an internet access",
+    #    help_text="Nombre d'abonnés à un accès Internet",
     #    default=0)
 
     # field outside of db-ffdn format:
@@ -110,9 +117,13 @@ class ISPInfo(SingleInstanceMixin, models.Model):
         verbose_name="serveur de listes", blank=True,
         help_text="URL du serveur de listes de discussions/diffusion")
 
+    class Meta:
+        verbose_name = "Information du FAI"
+        verbose_name_plural = "Informations du FAI"
+
     @property
     def version(self):
-        """Version of the API"""
+        """Version de l'API"""
         return API_VERSION
 
     @property
@@ -168,25 +179,33 @@ class ISPInfo(SingleInstanceMixin, models.Model):
 
 
 class OtherWebsite(models.Model):
-    name = models.CharField(max_length=512)
+    name = models.CharField(max_length=512, verbose_name="Nom")
     url = models.URLField(verbose_name="URL")
     isp = models.ForeignKey(ISPInfo)
 
+    class Meta:
+        verbose_name = "Autre site Internet"
+        verbose_name_plural = "Autres sites Internet"
+
 
 class RegisteredOffice(models.Model):
     """ http://json-schema.org/address """
-    post_office_box = models.CharField(max_length=512, blank=True)
-    extended_address = models.CharField(max_length=512, blank=True)
-    street_address = models.CharField(max_length=512, blank=True)
-    locality = models.CharField(max_length=512)
-    region = models.CharField(max_length=512)
-    postal_code = models.CharField(max_length=512, blank=True)
-    country_name = models.CharField(max_length=512)
+    post_office_box = models.CharField(max_length=512, blank=True, verbose_name="Boîte postale")
+    extended_address = models.CharField(max_length=512, blank=True, verbose_name="Adresse complémentaire")
+    street_address = models.CharField(max_length=512, blank=True, verbose_name="Adresse")
+    locality = models.CharField(max_length=512, verbose_name="Ville")
+    region = models.CharField(max_length=512, verbose_name="Région")
+    postal_code = models.CharField(max_length=512, blank=True, verbose_name="Code postal")
+    country_name = models.CharField(max_length=512, verbose_name="Pays")
     isp = models.OneToOneField(ISPInfo)
 
     # not in db.ffdn.org spec
     siret = FRSIRETField('SIRET')
 
+    class Meta:
+        verbose_name = "Siège social"
+        verbose_name_plural = "Sièges sociaux"
+
     def to_dict(self):
         d = dict()
         for field in ('post_office_box', 'extended_address', 'street_address',
@@ -202,11 +221,15 @@ class ChatRoom(models.Model):
         verbose_name="URL", max_length=256, validators=[chatroom_url_validator])
     isp = models.ForeignKey(ISPInfo)
 
+    class Meta:
+        verbose_name = "Salon de discussions"
+        verbose_name_plural = "Salons de discussions"
+
 
 class CoveredArea(models.Model):
-    name = models.CharField(max_length=512)
+    name = models.CharField(max_length=512, verbose_name="Nom")
 
-    technologies = MultiSelectField(choices=TECHNOLOGIES, max_length=42)
+    technologies = MultiSelectField(choices=TECHNOLOGIES, max_length=42, verbose_name="Technologie")
     # TODO: find a geojson library
     #area =
     isp = models.ForeignKey(ISPInfo)
@@ -215,6 +238,10 @@ class CoveredArea(models.Model):
         return {"name": self.name,
                 "technologies": self.technologies}
 
+    class Meta:
+        verbose_name = "Zone couverte"
+        verbose_name_plural = "Zones couvertes"
+
 
 class BankInfo(models.Model):
     """Information about bank account and the bank itself

+ 109 - 51
coin/members/admin.py

@@ -5,17 +5,19 @@ from django.shortcuts import render, get_object_or_404
 from django.contrib import admin
 from django.contrib import messages
 from django.contrib.auth.admin import UserAdmin
-from django.contrib.auth.models import Group
+from django.contrib.auth.models import Group, Permission
+from django.contrib.contenttypes.models import ContentType
 from django.http import HttpResponseRedirect
 from django.conf.urls import url
+from django.conf import settings
 from django.db.models.query import QuerySet
 from django.core.urlresolvers import reverse
 from django.utils.html import format_html
 
 from coin.members.models import (
-    Member, CryptoKey, LdapUser, MembershipFee, OfferSubscription)
+    Member, CryptoKey, LdapUser, MembershipFee, Offer, OfferSubscription, RowLevelPermission)
 from coin.members.membershipfee_filter import MembershipFeeFilter
-from coin.members.forms import MemberChangeForm, MemberCreationForm
+from coin.members.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.utils import delete_selected
 import autocomplete_light
 
@@ -35,17 +37,50 @@ class MembershipFeeInline(admin.TabularInline):
 class OfferSubscriptionInline(admin.TabularInline):
     model = OfferSubscription
     extra = 0
-    exclude = ('comments',)
-    readonly_fields = ('get_subscription_reference', 'subscription_date', 'resign_date',
-                       'commitment', 'offer')
 
-    show_change_link = True
+    writable_fields = ('subscription_date', 'resign_date', 'commitment', 'offer')
+    all_fields = ('get_subscription_reference',) + writable_fields
+
+    def get_fields(self, request, obj=None):
+        if obj:
+            return self.all_fields
+        else:
+            return self.writable_fields
+
+    def get_readonly_fields(self, request, obj=None):
+        # création ou superuser : lecture écriture
+        if not obj or request.user.is_superuser:
+            return ('get_subscription_reference',)
+        # modification : lecture seule seulement
+        else:
+            return self.all_fields
 
-    def has_add_permission(self, request, obj=None):
-        return False
+    show_change_link = True
 
+    def formfield_for_foreignkey(self, db_field, request, **kwargs):
+        if request.user.is_superuser:
+            return super(OfferSubscriptionInline, self).formfield_for_foreignkey(db_field, request, **kwargs)
+        else:
+            if db_field.name == "offer":
+                kwargs["queryset"] = Offer.objects.manageable_by(request.user)
+            return super(OfferSubscriptionInline, self).formfield_for_foreignkey(db_field, request, **kwargs)
+
+    def has_add_permission(self, request):
+        # - Quand on *crée* un membre on autorise à ajouter un abonnement
+        # - Quand on *édite* un membre, on interdit l'ajout d'abonnements (sauf
+        #   par le bureau) car cela permettrait de gagner à loisir accès à
+        #   toute fiche adhérent en lui ajoutant un abonnement à une offre dont
+        #   on a la gestion).
+        return (
+            request.resolver_match.view_name == 'admin:members_member_add'
+            or
+            request.user.is_superuser
+        )
+
+    # sinon on pourrait supprimer les abo qu'on ne peut pas gérer
+    # pourrait peut-être être plus fin, obj réfère ici au member de la page
     def has_delete_permission(self, request, obj=None):
-        return False
+        return request.user.is_superuser
 
 
 class MemberAdmin(UserAdmin):
@@ -59,45 +94,50 @@ class MemberAdmin(UserAdmin):
     actions = [delete_selected, 'set_as_member', 'set_as_non_member',
                'bulk_send_welcome_email', 'bulk_send_call_for_membership_fee_email']
 
-    form = MemberChangeForm
+    form = AdminMemberChangeForm
     add_form = MemberCreationForm
 
-    fieldsets = (
-        ('Adhérent', {'fields': (
-            ('status', 'resign_date'),
-            'type',
-            ('first_name', 'last_name', 'nickname'),
-            'organization_name',
-            'comments')}),
-        ('Coordonnées', {'fields': (
-            'email',
-            ('home_phone_number', 'mobile_phone_number'),
-            'address',
-            ('postal_code', 'city', 'country'))}),
-        ('Authentification', {'fields': (
-            ('username', 'password'))}),
-        ('Permissions', {'fields': (
-            ('is_active', 'is_staff', 'is_superuser'))}),
-        (None, {'fields': ('date_last_call_for_membership_fees_email',)})
-    )
-
-    add_fieldsets = (
-        ('Adhérent', {'fields': (
-            'status',
-            'type',
-            ('first_name', 'last_name', 'nickname'),
-            'organization_name',
-            'comments')}),
-        ('Coordonnées', {'fields': (
-            'email',
+    def get_fieldsets(self, request, obj=None):
+        coord_fieldset = ('Coordonnées', {'fields': (
+            ('email', 'send_membership_fees_email'),
             ('home_phone_number', 'mobile_phone_number'),
             'address',
-            ('postal_code', 'city', 'country'))}),
-        ('Authentification', {'fields': (
-            ('username', 'password'),)}),
-        ('Permissions', {'fields': (
-            ('is_active', 'is_staff', 'is_superuser', 'date_joined'))})
-    )
+            ('postal_code', 'city', 'country'))})
+        auth_fieldset = ('Authentification', {'fields': (
+            ('username', 'password'))})
+        perm_fieldset = ('Permissions', {'fields': (
+            ('is_active', 'is_staff', 'is_superuser', 'groups'))})
+
+        # if obj is null then it is a creation, otherwise it is a modification
+        if obj:
+            fieldsets = (
+                ('Adhérent', {'fields': (
+                    ('status', 'date_joined', 'resign_date'),
+                    'type',
+                    ('first_name', 'last_name', 'nickname'),
+                    'organization_name',
+                    'comments'
+                )}),
+                coord_fieldset,
+                auth_fieldset,
+                perm_fieldset,
+                (None, {'fields': ('date_last_call_for_membership_fees_email',)})
+            )
+        else:
+            fieldsets = (
+                ('Adhérent', {'fields': (
+                    ('status', 'date_joined'),
+                    'type',
+                    ('first_name', 'last_name', 'nickname'),
+                    'organization_name',
+                    'comments')}),
+                coord_fieldset,
+                auth_fieldset,
+                perm_fieldset
+            )
+        if settings.HANDLE_BALANCE:
+            fieldsets[0][1]['fields'] += ('balance',)
+        return fieldsets
 
     radio_fields = {"type": admin.HORIZONTAL}
 
@@ -105,16 +145,27 @@ class MemberAdmin(UserAdmin):
 
     inlines = [CryptoKeyInline, MembershipFeeInline, OfferSubscriptionInline]
 
+    def get_queryset(self, request):
+        qs = super(MemberAdmin, self).get_queryset(request)
+        if request.user.is_superuser:
+            return qs
+        else:
+            offers = Offer.objects.manageable_by(request.user)
+            return qs.filter(offersubscription__offer__in=offers).distinct()
+
     def get_readonly_fields(self, request, obj=None):
+        readonly_fields = []
         if obj:
             # Remove help_text for readonly field (can't do that in the Form
             # django seems to user help_text from model for readonly fields)
             username_field = [
                 f for f in obj._meta.fields if f.name == 'username']
             username_field[0].help_text = ''
-            return ['username', ]
-        else:
-            return []
+
+            readonly_fields.append('username')
+        if not request.user.is_superuser:
+            readonly_fields += ['is_active', 'is_staff', 'is_superuser', 'groups', 'date_last_call_for_membership_fees_email']
+        return readonly_fields
 
     def set_as_member(self, request, queryset):
         rows_updated = queryset.update(status='member')
@@ -192,13 +243,13 @@ class MemberAdmin(UserAdmin):
                                      email=member.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()
             messages.success(request,
                              "Le courriel de relance de cotisation a été "
                              "envoyé à {member} ({email})"\
                              .format(member=member, email=member.email))
-        elif cpt_success>1:
+        elif cpt_success > 1:
             messages.success(request,
                              "Le courriel de relance de cotisation a été "
                              "envoyé à {cpt} membres"\
@@ -212,7 +263,14 @@ class MembershipFeeAdmin(admin.ModelAdmin):
                     'payment_date')
     form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
 
+class RowLevelPermissionAdmin(admin.ModelAdmin):
+    def get_changeform_initial_data(self, request):
+        return {'content_type': ContentType.objects.get_for_model(OfferSubscription)}
+
+
+
 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(RowLevelPermission, RowLevelPermissionAdmin)

+ 10 - 7
coin/members/autocomplete_light_registry.py

@@ -8,10 +8,13 @@ from models import Member
 autocomplete_light.register(Member,
                             # Just like in ModelAdmin.search_fields
                             search_fields=[
-                                '^first_name', 'last_name', 'organization_name',
-                                'username', 'nickname'],
-                            # This will actually data-minimum-characters which
-                            # will set widget.autocomplete.minimumCharacters.
-                            autocomplete_js_attributes={
-                                'placeholder': 'Other model name ?', },
-                            )
+                                '^first_name', '^last_name', 'organization_name',
+                                '^username', '^nickname'],
+                            attrs={
+                                # This will set the input placeholder attribute:
+                                'placeholder': 'Nom/Prénom/Pseudo (min 3 caractères)',
+                                # Nombre minimum de caractères à saisir avant de compléter.
+                                # Fixé à 3 pour ne pas qu'on puisse avoir accès à la liste de tous les membres facilement quand on n'est pas superuser.
+                                'data-autocomplete-minimum-characters': 3,
+                            },
+)

+ 50 - 4
coin/members/forms.py

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 
 from django import forms
 from django.contrib.auth.forms import PasswordResetForm, ReadOnlyPasswordHashField
+from django.forms.utils import ErrorList
 
 from coin.members.models import Member
 
@@ -37,20 +38,18 @@ class MemberCreationForm(forms.ModelForm):
         return member
 
 
-class MemberChangeForm(forms.ModelForm):
-
+class AbstractMemberChangeForm(forms.ModelForm):
     """
     This form was inspired from django.contrib.auth.forms.UserChangeForm
     and adapted to coin specificities
     """
-    password = ReadOnlyPasswordHashField()
 
     class Meta:
         model = Member
         fields = '__all__'
 
     def __init__(self, *args, **kwargs):
-        super(MemberChangeForm, self).__init__(*args, **kwargs)
+        super(AbstractMemberChangeForm, self).__init__(*args, **kwargs)
         f = self.fields.get('user_permissions', None)
         if f is not None:
             f.queryset = f.queryset.select_related('content_type')
@@ -66,5 +65,52 @@ class MemberChangeForm(forms.ModelForm):
         return self.initial["username"]
 
 
+class AdminMemberChangeForm(AbstractMemberChangeForm):
+    password = ReadOnlyPasswordHashField()
+
+
+class SpanError(ErrorList):
+    def __unicode__(self):
+        return self.as_spans()
+    def __str__(self):
+        return self.as_spans()
+    def as_spans(self):
+        if not self: return ''
+        return ''.join(['<span class="error">%s</span>' % e for e in self])
+
+class PersonMemberChangeForm(AbstractMemberChangeForm):
+    """
+    Form use to allow natural person to change their info
+    """
+    class Meta:
+        model = Member
+        fields = ['first_name', 'last_name', 'email', 'nickname',
+                  'home_phone_number', 'mobile_phone_number',
+                  'address', 'postal_code', 'city', 'country']
+
+    def __init__(self, *args, **kwargs):
+        super(PersonMemberChangeForm, self).__init__(*args, **kwargs)
+        self.error_class = SpanError
+        for fieldname in self.fields:
+            self.fields[fieldname].help_text = None
+
+
+class OrganizationMemberChangeForm(AbstractMemberChangeForm):
+    """
+    Form use to allow organization to change their info
+    """
+    class Meta:
+        model = Member
+        fields = ['organization_name', 'email', 'nickname',
+                  'home_phone_number', 'mobile_phone_number',
+                  'address', 'postal_code', 'city', 'country']
+
+    def __init__(self, *args, **kwargs):
+        super(OrganizationChangeForm, self).__init__(*args, **kwargs)
+        self.error_class = SpanError
+        for fieldname in self.fields:
+            self.fields[fieldname].help_text = None
+
 class MemberPasswordResetForm(PasswordResetForm):
     pass
+

+ 2 - 1
coin/members/management/commands/call_for_membership_fees.py

@@ -43,7 +43,8 @@ class Command(BaseCommand):
 
         members = Member.objects.filter(status='member')\
                                 .annotate(end=Max('membership_fees__end_date'))\
-                                .filter(end__in=end_dates)
+                                .filter(end__in=end_dates)\
+                                .filter(send_membership_fees_email=True)
         if verbosity >= 2:
             self.stdout.write(
                 "Got {number} members.".format(number=members.count()))

+ 46 - 3
coin/members/management/commands/members_email.py

@@ -1,15 +1,58 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+import datetime
+
 from django.core.management.base import BaseCommand, CommandError
+from django.db.models import Q
 
 from coin.members.models import Member
-
+from coin.offers.models import Offer
+from coin.offers.models import OfferSubscription
 
 class Command(BaseCommand):
-    help = 'Returns the email addresses of all members, in a format suitable for bulk importing in Sympa'
+    help = """Returns email addresses of members in a format suitable for bulk importing in Sympa."""
+
+    def add_arguments(self, parser):
+        parser.add_argument('--subscribers', action='store_true',
+                            help='Return only the email addresses of subscribers to any offers.')
+        parser.add_argument('--offer', metavar='OFFER-ID or OFFER-REF',
+                            help='Return only the email addresses of subscribers to the specified offer')
 
     def handle(self, *args, **options):
-        emails = [m.email for m in Member.objects.filter(status='member')]
+        if options['subscribers']:
+            today = datetime.date.today()
+                        
+            offer_subscriptions = OfferSubscription.objects.filter(
+                Q(resign_date__gt=today)
+                | Q(resign_date__isnull=True)
+            )
+            members = [s.member for s in offer_subscriptions]
+        elif options['offer']:
+            try:
+                # Try to find the offer by its reference
+                offer = Offer.objects.get(reference=options['offer'])
+            except Offer.DoesNotExist:
+                try:
+                    # No reference found, maybe it's an offer_id
+                    offer_id = int(options['offer'])
+                    offer = Offer.objects.get(pk=offer_id)
+                except Offer.DoesNotExist:
+                    raise CommandError('Offer "%s" does not exist' % options['offer'])
+                except (IndexError, ValueError):
+                    raise CommandError('Please enter a valid offer reference or id')
+            today = datetime.date.today()
+
+            offer_subscriptions = OfferSubscription.objects.filter(
+                 # Fetch all OfferSubscription to the given Offer
+                Q(offer=offer)
+                # Check if OfferSubscription isn't resigned
+                & (Q(resign_date__isnull=True) | Q(resign_date__gt=today))
+            ).select_related('member')
+            members = [s.member for s in offer_subscriptions]
+        else:
+            members = Member.objects.filter(status='member')
+
+        emails = list(set([m.email for m in members if m.status == 'member']))
         for email in emails:
             self.stdout.write(email)

+ 19 - 0
coin/members/migrations/0014_member_balance.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0013_auto_20161015_1837'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='member',
+            name='balance',
+            field=models.DecimalField(default=0, verbose_name='account balance', max_digits=5, decimal_places=2),
+        ),
+    ]

+ 19 - 0
coin/members/migrations/0014_member_send_membership_fees_email.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0013_auto_20161015_1837'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='member',
+            name='send_membership_fees_email',
+            field=models.BooleanField(default=True, help_text="Certains membres n'ont pas \xe0 recevoir de relance (pr\xe9l\xe8vement automatique, membres d'honneurs, etc.)", verbose_name='relance de cotisation'),
+        ),
+    ]

+ 19 - 0
coin/members/migrations/0015_auto_20170824_2308.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0014_member_send_membership_fees_email'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='member',
+            name='send_membership_fees_email',
+            field=models.BooleanField(default=True, help_text="Pr\xe9cise si l'utilisateur doit recevoir des mails de relance pour la cotisation. Certains membres n'ont pas \xe0 recevoir de relance (pr\xe9l\xe8vement automatique, membres d'honneurs, etc.)", verbose_name='relance de cotisation'),
+        ),
+    ]

+ 15 - 0
coin/members/migrations/0016_merge.py

@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0015_auto_20170824_2308'),
+        ('members', '0014_member_balance'),
+    ]
+
+    operations = [
+    ]

+ 25 - 0
coin/members/migrations/0016_rowlevelpermission.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('auth', '0006_require_contenttypes_0002'),
+        ('offers', '0007_offersubscription_comments'),
+        ('members', '0015_auto_20170824_2308'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='RowLevelPermission',
+            fields=[
+                ('permission_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='auth.Permission')),
+                ('description', models.TextField(blank=True)),
+                ('offer', models.ForeignKey(verbose_name='Offre', to='offers.Offer', help_text="Offre dont l'utilisateur est autoris\xe9 \xe0 voir et modifier les membres et les abonnements.", null=True)),
+            ],
+            bases=('auth.permission',),
+        ),
+    ]

+ 15 - 0
coin/members/migrations/0017_merge.py

@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0016_rowlevelpermission'),
+        ('members', '0016_merge'),
+    ]
+
+    operations = [
+    ]

+ 73 - 2
coin/members/models.py

@@ -9,18 +9,33 @@ from django.db import models
 from django.db.models import Q, Max
 from django.db.models.signals import pre_save
 from django.dispatch import receiver
-from django.contrib.auth.models import AbstractUser
+from django.contrib.auth.models import AbstractUser, Permission, UserManager
+from django.contrib.contenttypes.models import ContentType
 from django.conf import settings
 from django.core.validators import RegexValidator
 from django.core.exceptions import ValidationError
 from django.utils import timezone
+from django.utils.text import slugify
 from ldapdb.models.fields import CharField, IntegerField, ListField
 
-from coin.offers.models import OfferSubscription
+from coin.offers.models import Offer, OfferSubscription
 from coin.mixins import CoinLdapSyncMixin
 from coin import utils
 
 
+
+class MemberManager(UserManager):
+    def manageable_by(self, user):
+        """" Renvoie la liste des members que l'utilisateur est autorisé à voir
+        dans l'interface d'administration.
+        """
+        if user.is_superuser:
+            return super(MemberManager, self).all()
+        else:
+            offers = Offer.objects.manageable_by(user)
+            return super(MemberManager, self).filter(offersubscription__offer__in=offers).distinct()
+
+
 class Member(CoinLdapSyncMixin, AbstractUser):
 
     # USERNAME_FIELD = 'login'
@@ -75,6 +90,13 @@ class Member(CoinLdapSyncMixin, AbstractUser):
     date_last_call_for_membership_fees_email = models.DateTimeField(null=True,
                         blank=True,
                         verbose_name="Date du dernier email de relance de cotisation envoyé")
+    send_membership_fees_email = models.BooleanField(
+        default=True, verbose_name='relance de cotisation',
+        help_text='Précise si l\'utilisateur doit recevoir des mails de relance pour la cotisation. Certains membres n\'ont pas à recevoir de relance (prélèvement automatique, membres d\'honneurs, etc.)')
+    balance = models.DecimalField(max_digits=5, decimal_places=2, default=0,
+                                  verbose_name='account balance')
+
+    objects = MemberManager()
 
     # Following fields are managed by the parent class AbstractUser :
     # username, first_name, last_name, email
@@ -276,6 +298,9 @@ class Member(CoinLdapSyncMixin, AbstractUser):
 
         :param auto: is it an auto email? (changes slightly template content)
         """
+        if auto and not self.send_membership_fees_email:
+            return False
+
         from dateutil.relativedelta import relativedelta
         from coin.isp_database.models import ISPInfo
 
@@ -486,6 +511,7 @@ class LdapUser(ldapdb.models.Model):
 # managed = False  # Indique à Django de ne pas intégrer ce model en base
 
 
+
 @receiver(pre_save, sender=Member)
 def define_username(sender, instance, **kwargs):
     """
@@ -505,3 +531,48 @@ def define_display_name(sender, instance, **kwargs):
     if not instance.display_name:
         instance.display_name = '%s %s' % (instance.first_name,
                                            instance.last_name)
+
+
+
+class RowLevelPermission(Permission):
+    offer = models.ForeignKey(
+        'offers.Offer', null=True, verbose_name="Offre",
+        help_text="Offre dont l'utilisateur est autorisé à voir et modifier les membres et les abonnements.")
+    description = models.TextField(blank=True)
+
+    def save(self, *args, **kwargs):
+        """
+        Lors de la sauvegarde d'une RowLevelPermission. Si le champ codename n'est pas définit,
+        le calcul automatiquement.
+        """
+        if not self.codename:
+            self.codename = self.generate_codename()
+        return super(RowLevelPermission, self).save(*args, **kwargs)
+
+    def generate_codename(self):
+        """
+        Calcule le codename automatiquement en fonction du name.
+        """
+        # Convertit en ASCII. Convertit les espaces en tirets. Enlève les caractères qui ne sont ni alphanumériques, ni soulignements, ni tirets. Convertit en minuscules. Les espaces en début et fin de chaîne sont aussi enlevés
+        codename = slugify(self.name)
+        # Maximum de 30 char
+        codename = codename[:30]
+        # Recherche dans les membres existants un codename identique
+        perm = Permission.objects.filter(codename=codename)
+        base_codename = codename
+        incr = 2
+        # Tant qu'une permission est trouvée, incrémente un entier à la fin
+        while perm:
+            codename = base_codename + str(incr)
+            perm = Permission.objects.filter(codename=codename)
+            incr += 1
+        return codename
+
+    class Meta:
+        verbose_name = 'permission fine'
+        verbose_name_plural = 'permissions fines'
+
+
+RowLevelPermission._meta.get_field('codename').blank = True
+RowLevelPermission._meta.get_field('codename').help_text = 'Laisser vide pour le générer automatiquement'
+RowLevelPermission._meta.get_field('content_type').help_text = "Garder 'abonnement' pour une utilisation normale"

+ 18 - 8
coin/members/templates/members/detail.html

@@ -98,14 +98,24 @@
 </div>
 <div class="row">
     <div class="large-12 columns">
-        <p>
-            Pour modifier vos informations personnelles et vos coordonnées, veuillez en faire la demande
-            {% if branding.administrative_email %}
-             par email à <a href="mailto:{{ branding.administrative_email }}">{{ branding.administrative_email }}</a>.
-            {% else %}
-             à l'association.
-            {% endif%}
-        </p>
+        {% if form %}
+            <form method="post" action="">
+                {% csrf_token %}
+                <fieldset class="module aligned wide">
+                {{ form.as_p }}
+                </fieldset>
+                <input type="submit" class="button radius" value="Modifier"/>
+            </form>
+        {% else %}
+            <p>
+                Pour modifier vos informations personnelles et vos coordonnées, veuillez en faire la demande
+                {% if branding.administrative_email %}
+                par email à <a href="mailto:{{ branding.administrative_email }}">{{ branding.administrative_email }}</a>.
+                {% else %}
+                à l'association.
+                {% endif%}
+            </p>
+        {% endif %}
     </div>
 </div>
 

+ 30 - 0
coin/members/templates/members/invoices.html

@@ -1,6 +1,11 @@
 {% extends "base.html" %}
 
 {% block content %}
+
+{% if handle_balance %}
+	<h2>Balance : {{ balance|floatformat }} €</h2>
+{% endif %}
+
 <h2>Mes factures</h2>
 
 <table id="member_invoices" class="full-width">
@@ -28,6 +33,31 @@
     </tbody>
 </table>
 
+
+<h2>Mes paiements</h2>
+
+<table id="member_payments" class="full-width">
+    <thead>
+        <tr>
+            <th>Date</th>
+            <th>Montant</th>
+            <th>Alloué</th>
+        </tr>
+    </thead>
+    <tbody>
+        {% for payment in payments %}
+        <tr>
+            <td>{{ payment.date }}</td>
+            <td>{{ payment.amount }}</td>
+            <td>{{ payment.amount_already_allocated }}</td>
+        </tr>
+        {% empty %}
+        <tr class="placeholder"><td colspan="6">Aucun paiement.</td></tr>
+        {% endfor %}
+    </tbody>
+</table>
+
+
 <h2>Coordonnées bancaires</h2>
 <div id="payment-howto" class="panel">
     {% include "billing/payment_howto.html" %}

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

@@ -7,9 +7,9 @@
     <div class="large-12 columns">
         <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>
 

+ 29 - 6
coin/members/views.py

@@ -2,11 +2,11 @@
 from __future__ import unicode_literals
 
 from django.template import RequestContext
-from django.shortcuts import render_to_response
+from django.shortcuts import render_to_response, render
 from django.contrib.auth.decorators import login_required
 from django.http import Http404
 from django.conf import settings
-
+from forms import PersonMemberChangeForm, OrganizationMemberChangeForm
 
 @login_required
 def index(request):
@@ -18,10 +18,28 @@ def index(request):
 
 @login_required
 def detail(request):
+
     membership_info_url = settings.MEMBER_MEMBERSHIP_INFO_URL
-    return render_to_response('members/detail.html',
-                              {'membership_info_url': membership_info_url},
-                              context_instance=RequestContext(request))
+    context={
+        'membership_info_url': membership_info_url,
+    }
+
+    if settings.MEMBER_CAN_EDIT_PROFILE:
+        if request.user.type == "natural_person":
+            form_cls = PersonMemberChangeForm
+        else:
+            form_cls = OrganizationMemberChangeForm
+
+        if request.method == "POST":
+            form = form_cls(data = request.POST, instance = request.user)
+            if form.is_valid():
+                form.save()
+        else:
+            form = form_cls(instance = request.user)
+
+        context['form'] = form
+
+    return render(request, 'members/detail.html', context)
 
 
 @login_required
@@ -37,10 +55,15 @@ def subscriptions(request):
 
 @login_required
 def invoices(request):
+    balance  = request.user.balance
     invoices = request.user.invoices.filter(validated=True).order_by('-date')
+    payments = request.user.payments.filter().order_by('-date')
 
     return render_to_response('members/invoices.html',
-                              {'invoices': invoices},
+                              {'balance' : balance, 
+                               'handle_balance' : settings.HANDLE_BALANCE, 
+                               'invoices': invoices, 
+                               'payments': payments},
                               context_instance=RequestContext(request))
 
 

+ 25 - 1
coin/offers/admin.py

@@ -2,8 +2,10 @@
 from __future__ import unicode_literals
 
 from django.contrib import admin
+from django.db.models import Q
 from polymorphic.admin import PolymorphicChildModelAdmin
 
+from coin.members.models import Member
 from coin.offers.models import Offer, OfferSubscription
 from coin.offers.offersubscription_filter import\
             OfferSubscriptionTerminationFilter,\
@@ -45,7 +47,29 @@ class OfferSubscriptionAdmin(admin.ModelAdmin):
                 'resign_date',
                 'comments'
              )
-    form = autocomplete_light.modelform_factory(OfferSubscription, fields='__all__')
+    # Si c'est un super user on renvoie un formulaire avec tous les membres et toutes les offres (donc autocomplétion pour les membres)
+    def get_form(self, request, obj=None, **kwargs):
+        if request.user.is_superuser:
+            kwargs['form'] = autocomplete_light.modelform_factory(OfferSubscription, fields='__all__')
+        return super(OfferSubscriptionAdmin, self).get_form(request, obj, **kwargs)
+
+    # Si pas super user on restreint les membres et offres accessibles
+    def formfield_for_foreignkey(self, db_field, request, **kwargs):
+        if not request.user.is_superuser:
+            if db_field.name == "member":
+                kwargs["queryset"] = Member.objects.manageable_by(request.user)
+            if db_field.name == "offer":
+                kwargs["queryset"] = Offer.objects.filter(id__in=[p.id for p in Offer.objects.manageable_by(request.user)])
+        return super(OfferSubscriptionAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
+
+    # Si pas super user on restreint la liste des offres que l'on peut voir
+    def get_queryset(self, request):
+        qs = super(OfferSubscriptionAdmin, self).get_queryset(request)
+        if request.user.is_superuser:
+            return qs
+        else:
+            offers = Offer.objects.manageable_by(request.user)
+            return qs.filter(offer__in=offers)
 
     def get_inline_instances(self, request, obj=None):
         """

+ 0 - 19
coin/offers/management/commands/subscribers_email.py

@@ -1,19 +0,0 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals
-
-import datetime
-
-from django.core.management.base import BaseCommand, CommandError
-from django.db.models import Q
-
-from coin.offers.models import OfferSubscription
-
-
-class Command(BaseCommand):
-    help = 'Returns the email addresses of all subscribers, in a format suitable for bulk importing in Sympa'
-
-    def handle(self, *args, **options):
-        emails = [s.member.email for s in OfferSubscription.objects.filter(Q(resign_date__gt=datetime.date.today) | Q(resign_date__isnull=True))]
-        # Use a set to ensure uniqueness
-        for email in set(emails):
-            self.stdout.write(email)

+ 18 - 0
coin/offers/models.py

@@ -7,8 +7,24 @@ from django.conf import settings
 from django.db import models
 from django.db.models import Count, Q
 from django.core.validators import MinValueValidator
+from django.contrib.contenttypes.models import ContentType
 
 
+class OfferManager(models.Manager):
+    def manageable_by(self, user):
+        """" Renvoie la liste des offres dont l'utilisateur est autorisé à
+        voir les membres et les abonnements dans l'interface d'administration.
+        """
+        from coin.members.models import RowLevelPermission
+        # toutes les permissions appliquées à cet utilisateur
+        # (liste de chaines de caractères)
+        perms = user.get_all_permissions()
+        allowedcodenames = [ s.split('offers.',1)[1] for s in perms if s.startswith('offers.')]
+        # parmi toutes les RowLevelPermission, celles qui sont relatives à des OfferSubscription et qui sont dans allowedcodenames
+        rowperms = RowLevelPermission.objects.filter(content_type=ContentType.objects.get_for_model(OfferSubscription), codename__in=allowedcodenames)
+        # toutes les Offers pour lesquelles il existe une RowLevelpermission correspondante dans rowperms
+        return super(OfferManager, self).filter(rowlevelpermission__in=rowperms).distinct()
+
 class Offer(models.Model):
     """Description of an offer available to subscribers.
 
@@ -44,6 +60,8 @@ class Offer(models.Model):
                                        verbose_name='n\'est pas facturable',
                                        help_text='L\'offre ne sera pas facturée par la commande charge_members')
 
+    objects = OfferManager()
+
     def get_configuration_type_display(self):
         """
         Renvoi le nom affichable du type de configuration

+ 15 - 0
coin/settings_base.py

@@ -176,6 +176,8 @@ EXTRA_INSTALLED_APPS = tuple()
 LOGGING = {
     'version': 1,
     'disable_existing_loggers': False,
+    'formatters': {
+    },
     'filters': {
         'require_debug_false': {
             '()': 'django.utils.log.RequireDebugFalse'
@@ -201,6 +203,10 @@ LOGGING = {
             'handlers': ['console'],
             'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
         },
+        "coin.billing": {
+            'handlers': ['console'],
+            'level': 'INFO',
+        }
     }
 }
 
@@ -269,3 +275,12 @@ MEMBER_CAN_EDIT_VPS_CONF = True
 
 # Allow user to edit their VPN Info
 MEMBER_CAN_EDIT_VPN_CONF = True
+
+# Member can edit their own data
+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

+ 154 - 0
doc/user/permissions.md

@@ -0,0 +1,154 @@
+Permissions (sur l'interface d'administration)
+==============================================
+
+Par défaut, un membre n'a pas accès à l'interface d'administration.
+
+Organisation
+------------
+
+Les permissions d'un membre se changent dans sa fiche. Seuls les
+super-utilisateurs peuvent modifier les permissions.
+
+### Statut équipe
+
+Il permet d'autoriser un membre à se connecter à l'interface
+d'administration. Un bouton *« Administration »* apparaîtra alors dans son
+menu. En l'absence d'appartenance à un [groupe](#groupes) ou
+du [statut super-utilisateur](#statut-super-utilisateur), le statut équipe
+donne accès à une interface d'administration vide.
+
+### Statut super-utilisateur
+
+Un membre avec le *statut super-utilisateur* peut lire et modifier toutes les
+informations gérées par coin. C'est typiquement un statut à réserver aux
+membres du bureau.
+
+### Groupes
+
+Les *groupes* permettent simplement de réunir les membres par niveau
+d'accès. Un *groupe* inclut donc un ou plusieurs *membres* et se voit attribuer
+une ou plusieurs [permissions](#permissions).
+
+Un membre peut appartenir à plusieurs groupes.
+
+### Permissions
+
+Les permissions permettent de choisir précisément à quelles données peuvent
+accéder les membres d'un [groupe](#groupe).
+
+#### Permissions par opération
+
+On peut gérer les permissions d'accès pour chaque opération réalisable dans
+coin. Une opération est la combinaison d'un *type d'opération* et d'un *type de
+donnée*.
+
+- Les **types d'opérations** sont : *création*, *suppression* *modification*.
+- Les **types de données** principaux sont : membre, abonnement, offre… La
+liste complète est affichée aux super-utilisateurs sur la page d'accueil de
+l'administration.
+
+
+**NB**: Le droit de *lecture* est accordé avec le droit de *modification*. Le droit
+de *lecture seule* n'existe donc pas.
+
+Les permissions sur les *abonnements*, les *offres* et les *membres* sont de plus
+restreintes par les [permissions fines](#permissions-fines-par-offre).
+
+#### Permissions fines (par offre)
+
+Ce sont des permissions qui permettent de n'autoriser l'accès qu'à une partie
+des données en fonction de leur contenu. Ces permissions ne se substituent pas
+aux [permissions par opération](#permissions-par-operation), elles en limitent
+le champ d'application.
+
+Les *types de données* dont l'accès est limité par les *permissions fines* sont :
+
+- offres
+- abonnements
+- membre
+
+Les *permissions fines* permettent ce genre de logique :
+
+- Les membres du groupe « Admins VPN » n'ont accès qu'à ce qui concerne les
+  abonnés et abonnements VPN.
+- Les membres du groupe « Wifi Machin » n'ont accès qu'à ce qui concerne les
+  abonnements wifi du quartier machin
+- etc…
+
+Le critère sur lequel une donnée est accessible ou non est donc l'offre
+souscrite.
+
+
+Exemples
+--------
+
+## Exemple pour un groupe gérant le matériel et les emprunts
+
+1. Créer un **groupe** « Matos » (dans la section *Auth*) avec toutes les
+   permissions mentionnant l'application « hardware_provisioning ».
+2. Pour chaque *membre* qui va gérer le matos, aller sur sa fiche, et dans la
+   rubrique *Permissions* :
+
+  - activer son *Statut équipe*
+  - l'ajouter au groupe  « Matos »
+
+**NB:** Quand un membre de notre groupe « Matos » déclare un nouvel emprunt, il
+devra tapper au moins 3 caractères du nom du membre qui emprunte, de cette façon
+un utilisateur qui n'est pas super-utilisateur n'a pas accès facilement à la
+liste de tous les membres.
+
+
+
+## Exemple pour un groupe gérant les abonnements ADSL
+
+1. **Pour chaque offre ADSL, créer une Row Level Permission** (dans la section
+   *Members*) correspondante (c'est pénible mais on est obligé de faire une
+   permission par *offre*). Par exemple, si on a deux offres ADSL :
+
+   | Champ          | Valeur                            	|
+   |----------------|---------------------------------------|
+   | Nom          	| Permission ADSL Marque blanche    	|
+   | Content Type 	| abonnement                        	|
+   | Nom de code  	| perm-adsl-marque-blanche          	|
+   | Offre        	| Marque blanche FDN - 32 € / mois  	|
+
+ et
+
+   | Champ          | Valeur                            	            |
+   |----------------|---------------------------------------------------|
+   | Nom          	| Permission ADSL Marque blanche (préférentiel)    	|
+   | Content Type 	| abonnement                        	            |
+   | Nom de code  	| perm-adsl-marque-blanche-pref        	            |
+   | Offre        	| Marque blanche FDN - 32 € / mois  	            |
+
+2. **Créer un Groupe** (dans la section *Auth*) nommé « ADSL » avec les
+   permissions suivantes :
+  - `membres | membre | Can add membre` pour que les *membres* du groupe
+    puissent créer de nouvelles fiches membre
+  - `membres | membre | Can change membre` pour qu'ils puissent voir et éditer
+    les infos des membres, ils n'auront accès qu'aux membres qui ont souscrit à
+    un abonnement ADSL
+  - `offers | abonnement | Can add abonnement` pour qu'ils puissent une
+    souscription d'abonnement
+  - `offers | abonnement | Can change abonnement` pour qu'ils puissent modifier
+    une souscription abonnement
+  - `offers | abonnement | Can delete abonnement` si l'on veut qu'ils puissent
+    supprimer des abonnements (à réfléchir, peut être souhaitable ou non)
+  - `offers | abonnement | perm-adsl-marque-blanche` pour qu'ils puissent avoir
+    accès aux membres qui ont souscrit à l'offre correspondante (permission
+    qu'on vient de créer au 1.)
+  - `offers | abonnement | perm-adsl-marque-blanche-pref` (idem)
+
+3. **Pour chaque membre** qui va gérer l'ADSL, aller sur sa fiche et dans la
+   rubrique *Permissions* :
+  - lui ajouter le *Statut équipe* (afin qu'il puisse se connecter à l'interface d'admin)
+  - l'ajouter au groupe « ADSL »
+
+Les membres du groupe peuvent maintenant ajouter / modifier des membres et
+des abonnements.
+
+**Attention :** pour respecter la vie privée, les membres du groupe n'ont accès
+qu'aux membres qui ont un abonnement ADSL. Donc s'ils veulent enregistrer un
+nouveau membre il faut renseigner son abonnement *au moment de la création de
+la fiche membre* (en bas du formulaire membre) ; sinon la fiche du nouveau
+membre va être créée mais sera invisible (erreur 404, sauf pour le bureau).

+ 52 - 5
hardware_provisioning/admin.py

@@ -5,9 +5,11 @@ from __future__ import unicode_literals
 
 from django.contrib import admin
 from django.contrib.auth import get_user_model
-from django.utils import timezone
+from django.forms import ModelChoiceField
+import autocomplete_light
 
 from .models import ItemType, Item, Loan, Storage
+import coin.members.admin
 
 
 User = get_user_model()
@@ -40,6 +42,7 @@ class AvailabilityFilter(admin.SimpleListFilter):
         return [
             ('available', 'Disponible'),
             ('borrowed', 'Emprunté'),
+            ('deployed', 'Déployé'),
         ]
 
     def queryset(self, request, queryset):
@@ -47,6 +50,8 @@ class AvailabilityFilter(admin.SimpleListFilter):
             return queryset.available()
         elif self.value() == 'borrowed':
             return queryset.borrowed()
+        elif self.value() == 'deployed':
+            return queryset.deployed()
         else:
             return queryset
 
@@ -54,17 +59,20 @@ class AvailabilityFilter(admin.SimpleListFilter):
 @admin.register(Item)
 class ItemAdmin(admin.ModelAdmin):
     list_display = (
-        'designation', 'type', 'mac_address', 'serial', 'owner',
-        'buy_date', 'is_available')
+        'designation', 'mac_address', 'serial', 'owner',
+        'buy_date', 'deployed', 'is_available', 'storage')
     list_filter = (
-        AvailabilityFilter, 'type__name', 'storage',
+        AvailabilityFilter, 'type', 'storage',
         'buy_date', OwnerFilter)
     search_fields = (
         'designation', 'mac_address', 'serial',
         'owner__email', 'owner__nickname',
         'owner__first_name', 'owner__last_name')
+    save_as = True
     actions = ['give_back']
 
+    form = autocomplete_light.modelform_factory(Loan, fields='__all__')
+
     def give_back(self, request, queryset):
         for item in queryset.filter(loans__loan_date_end=None):
             item.give_back()
@@ -119,9 +127,15 @@ class BorrowerFilter(admin.SimpleListFilter):
             return queryset
 
 
+class ItemChoiceField(ModelChoiceField):
+    # On surcharge cette méthode pour afficher mac et n° de série dans le menu
+    # déroulant de sélection d'un objet dans la création d'un prêt.
+    def label_from_instance(self, obj):
+        return obj.designation + ' ' + obj.get_mac_and_serial()
+
 @admin.register(Loan)
 class LoanAdmin(admin.ModelAdmin):
-    list_display = ('item', 'get_mac_or_serial', 'user', 'loan_date', 'loan_date_end')
+    list_display = ('item', 'get_mac_and_serial', 'user', 'loan_date', 'loan_date_end')
     list_filter = (StatusFilter, BorrowerFilter, 'item__designation')
     search_fields = (
         'item__designation',
@@ -134,6 +148,15 @@ class LoanAdmin(admin.ModelAdmin):
             loan_date_end=datetime.now())
     end_loan.short_description = 'Mettre fin au prêt'
 
+    form = autocomplete_light.modelform_factory(Loan, fields='__all__')
+
+    def formfield_for_foreignkey(self, db_field, request, **kwargs):
+        if db_field.name == 'item':
+            kwargs['queryset'] = Item.objects.all()
+            return ItemChoiceField(**kwargs)
+        else:
+            return super(LoanAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)
+
 
 @admin.register(Storage)
 class StorageAdmin(admin.ModelAdmin):
@@ -145,3 +168,27 @@ class StorageAdmin(admin.ModelAdmin):
         else:
             return obj.notes
     truncated_notes.short_description = 'notes'
+
+class LoanInline(admin.TabularInline):
+    model = Loan
+    extra = 0
+    exclude = ('notes',)
+    readonly_fields = ('item', 'get_mac_and_serial', 'loan_date', 'loan_date_end', 'is_running')
+
+    show_change_link = True
+
+    def get_queryset(self, request):
+        qs = super(LoanInline, self).get_queryset(request)
+        return qs.order_by('-loan_date_end')
+
+    def has_add_permission(self, request, obj=None):
+        return False
+
+    def has_delete_permission(self, request, obj=None):
+        return False
+
+class MemberAdmin(coin.members.admin.MemberAdmin):
+    inlines = coin.members.admin.MemberAdmin.inlines + [LoanInline]
+
+admin.site.unregister(coin.members.admin.Member)
+admin.site.register(coin.members.admin.Member, MemberAdmin)

+ 1 - 1
hardware_provisioning/app.py

@@ -7,5 +7,5 @@ import coin.apps
 
 class HardwareProvisioningConfig(AppConfig, coin.apps.AppURLs):
     name = 'hardware_provisioning'
-    verbose_name = 'prêt de matériel'
+    verbose_name = 'Prêt de matériel'
     exported_urlpatterns = [('hardware_provisioning', 'hardware_provisioning.urls')]

+ 31 - 0
hardware_provisioning/migrations/0015_auto_20170802_1701.py

@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+from django.conf import settings
+import hardware_provisioning.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0014_auto_20170422_1847'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='item',
+            name='deployed',
+            field=models.BooleanField(default=False, help_text='Cocher si le mat\xe9riel est en production', verbose_name='d\xe9ploy\xe9'),
+        ),
+        migrations.AlterField(
+            model_name='item',
+            name='mac_address',
+            field=hardware_provisioning.fields.MACAddressField(null=True, max_length=17, blank=True, help_text='Pr\xe9f\xe9rable au n\xb0 de s\xe9rie si possible', unique=True, verbose_name='addresse MAC'),
+        ),
+        migrations.AlterField(
+            model_name='item',
+            name='owner',
+            field=models.ForeignKey(related_name='items', blank=True, to=settings.AUTH_USER_MODEL, help_text="Dans le cas de mat\xe9riel n'appartenant pas \xe0 l'association", null=True, verbose_name='Propri\xe9taire'),
+        ),
+    ]

+ 35 - 0
hardware_provisioning/migrations/0016_auto_20170802_2021.py

@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+from django.conf import settings
+import hardware_provisioning.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0015_auto_20170802_1701'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='item',
+            name='deployed',
+        ),
+        migrations.AlterField(
+            model_name='item',
+            name='mac_address',
+            field=hardware_provisioning.fields.MACAddressField(null=True, max_length=17, blank=True, help_text='pr\xe9f\xe9rable au n\xb0 de s\xe9rie si possible', unique=True, verbose_name='adresse MAC'),
+        ),
+        migrations.AlterField(
+            model_name='item',
+            name='owner',
+            field=models.ForeignKey(related_name='items', blank=True, to=settings.AUTH_USER_MODEL, help_text="dans le cas de mat\xe9riel n'appartenant pas \xe0 l'association", null=True, verbose_name='Propri\xe9taire'),
+        ),
+        migrations.AlterField(
+            model_name='item',
+            name='serial',
+            field=models.CharField(null=True, max_length=250, blank=True, help_text='ou toute autre r\xe9f\xe9rence unique', unique=True, verbose_name='N\xb0 de s\xe9rie'),
+        ),
+    ]

+ 19 - 0
hardware_provisioning/migrations/0017_item_deployed.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0016_auto_20170802_2021'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='item',
+            name='deployed',
+            field=models.BooleanField(default=False, help_text='Cocher si le mat\xe9riel est en production', verbose_name='d\xe9ploy\xe9'),
+        ),
+    ]

+ 37 - 16
hardware_provisioning/models.py

@@ -2,6 +2,7 @@
 
 from __future__ import unicode_literals
 from django.db import models
+from django.db.models import Q
 from django.conf import settings
 from django.utils import timezone
 
@@ -24,11 +25,22 @@ class ItemQuerySet(models.QuerySet):
         return Loan.objects.running().values_list('item', flat=True)
 
     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):
         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):
     type = models.ForeignKey(ItemType, verbose_name='type de matériel',
@@ -40,13 +52,13 @@ class Item(models.Model):
         null=True, blank=True,
         help_text='Laisser vide si inconnu')
     mac_address = MACAddressField(
-        verbose_name='addresse MAC',
+        verbose_name='adresse MAC',
         blank=True, null=True, unique=True,
         help_text="préférable au n° de série si possible")
     serial = models.CharField(
         verbose_name='N° de série',
         max_length=250, blank=True, null=True, unique=True,
-        help_text='ou toute autre référence unique)')
+        help_text='ou toute autre référence unique')
     buy_date = models.DateField(verbose_name='date d’achat' , blank=True , null=True)
     owner = models.ForeignKey(
         settings.AUTH_USER_MODEL,
@@ -54,6 +66,8 @@ class Item(models.Model):
         related_name='items',
         null=True, blank=True,
         help_text="dans le cas de matériel n'appartenant pas à l'association")
+    deployed = models.BooleanField(verbose_name='déployé', default=False,
+                                   help_text='Cocher si le matériel est en production')
     comment = models.TextField(verbose_name='commentaire', blank=True,
                                null=True)
 
@@ -81,17 +95,24 @@ class Item(models.Model):
 
     def is_available(self):
         """
-        Returns the status of the Item. If a Loan without an end date exists,
-        returns False (else True).
+        Returns the status of the Item. If a running loan exists,
+        or if the item is deployed, returns False (else True).
         """
-        if self.loans.running().exists():
-            return False
-        return True
+        return (not self.deployed) and (not self.loans.running().exists())
     is_available.boolean = True
     is_available.short_description = 'disponible'
 
+    def get_mac_and_serial(self):
+        mac = self.mac_address
+        serial = self.serial
+        if mac and serial:
+            return "{} / {}".format(mac, serial)
+        else:
+            return mac or serial or ''
+
     class Meta:
         verbose_name = 'objet'
+        ordering = ['designation', 'mac_address', 'serial']
 
     def give_back(self, storage=None):
         self.storage = storage
@@ -128,19 +149,19 @@ class Loan(models.Model):
         return 'prêt de {item} à {user}'.format(
             item=self.item, user=self.user)
 
-    def get_mac_or_serial(self):
-        mac = self.item.mac_address
-        serial = self.item.serial
-        if mac and serial:
-            return "{} / {}".format(mac, serial)
-        else:
-            return mac or serial or ''
+    def get_mac_and_serial(self):
+        return self.item.get_mac_and_serial()
 
-    get_mac_or_serial.short_description = "Adresse MAC / n° de série"
+    get_mac_and_serial.short_description = "Adresse MAC / n° de série"
 
     def user_can_close(self, user):
         return (not self.item.is_available()) and (self.user == user)
 
+    def is_running(self):
+        return not self.loan_date_end or self.loan_date_end > timezone.now()
+    is_running.boolean = True
+    is_running.short_description = 'En cours ?'
+
     class Meta:
         verbose_name = 'prêt d’objet'
         verbose_name_plural = 'prêts d’objets'

+ 46 - 11
hardware_provisioning/tests.py

@@ -14,20 +14,30 @@ def localize(naive_dt):
         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):
-        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):
-        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(
             item=self.item, user=self.member,
             loan_date=loan_start_date)
@@ -37,3 +47,28 @@ class HardwareLoaningTestCase(TestCase):
         loan.item.give_back()
         self.assertEqual(Loan.objects.running().count(), 0)
         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)

+ 4 - 2
requirements.txt

@@ -1,9 +1,9 @@
 Django>=1.8.17,<1.9
-psycopg2==2.5.2
+psycopg2==2.5.4
 python-ldap==2.4.15
 wsgiref==0.1.2
 python-dateutil==2.2
-django-autocomplete-light==2.1.1
+django-autocomplete-light>=2.2.10,<2.3
 django-activelink==0.4
 html2text
 django-polymorphic==0.7.2
@@ -16,3 +16,5 @@ feedparser
 six==1.10.0
 WeasyPrint==0.31
 freezegun==0.3.8
+pytz>=2018.5
+unidecode>=1.0,<1.1

+ 5 - 5
vpn/admin.py

@@ -54,11 +54,11 @@ class VPNConfigurationAdmin(ConfigurationAdminFormMixin, PolymorphicChildModelAd
 
     def activate(self, request, queryset):
         self.set_activation(request, queryset, True)
-    activate.short_description = "Activate selected VPNs"
+    activate.short_description = "Activer les VPN sélectionnés"
 
     def deactivate(self, request, queryset):
         self.set_activation(request, queryset, False)
-    deactivate.short_description = "Deactivate selected VPNs"
+    deactivate.short_description = "Désactiver les VPN sélectionnés"
 
     def generate_endpoints_generic(self, request, queryset, v4=True, v6=True):
         count = 0
@@ -72,14 +72,14 @@ class VPNConfigurationAdmin(ConfigurationAdminFormMixin, PolymorphicChildModelAd
 
     def generate_endpoints(self, request, queryset):
         self.generate_endpoints_generic(request, queryset)
-    generate_endpoints.short_description = "Generate IPv4 and IPv6 endpoints"
+    generate_endpoints.short_description = "Attribuer des adresses IPv4 et IPv6"
 
     def generate_endpoints_v4(self, request, queryset):
         self.generate_endpoints_generic(request, queryset, v6=False)
-    generate_endpoints_v4.short_description = "Generate IPv4 endpoints"
+    generate_endpoints_v4.short_description = "Attribuer des adresses IPv4"
 
     def generate_endpoints_v6(self, request, queryset):
         self.generate_endpoints_generic(request, queryset, v4=False)
-    generate_endpoints_v6.short_description = "Generate IPv6 endpoints"
+    generate_endpoints_v6.short_description = "Attribuer des adresses IPv6"
 
 admin.site.register(VPNConfiguration, VPNConfigurationAdmin)

+ 23 - 0
vpn/migrations/0002_auto_20170802_2021.py

@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('vpn', '0001_squashed_0002_remove_vpnconfiguration_comment'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='vpnconfiguration',
+            options={'verbose_name': 'VPN', 'verbose_name_plural': 'VPN'},
+        ),
+        migrations.AlterField(
+            model_name='vpnconfiguration',
+            name='login',
+            field=models.CharField(help_text='Laisser vide pour une g\xe9n\xe9ration automatique', unique=True, max_length=50, verbose_name='identifiant', blank=True),
+        ),
+    ]

+ 2 - 1
vpn/models.py

@@ -27,7 +27,7 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
     activated = models.BooleanField(default=False, verbose_name='activé')
     login = models.CharField(max_length=50, unique=True, blank=True,
                              verbose_name="identifiant",
-                             help_text="leave empty for automatic generation")
+                             help_text="Laisser vide pour une génération automatique")
     password = models.CharField(max_length=256, verbose_name="mot de passe",
                                 blank=True, null=True)
     ipv4_endpoint = InetAddressField(validators=[validation.validate_v4],
@@ -152,6 +152,7 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
 
     class Meta:
         verbose_name = 'VPN'
+        verbose_name_plural = 'VPN'
 
 
 class LdapVPNConfig(ldapdb.models.Model):