Browse Source

Merge with master

root 7 years ago
parent
commit
7f9a763980
53 changed files with 2467 additions and 213 deletions
  1. 2 1
      .gitignore
  2. 74 7
      README.md
  3. 53 0
      Vagrantfile
  4. 76 7
      coin/billing/admin.py
  5. 338 0
      coin/billing/management/commands/import_payments_from_csv.py
  6. 37 0
      coin/billing/management/commands/send_reminders_for_unpaid_bills.py
  7. 20 0
      coin/billing/migrations/0005_auto_20170608_2213.py
  8. 19 0
      coin/billing/migrations/0006_auto_20170608_2305.py
  9. 19 0
      coin/billing/migrations/0007_auto_20170801_1530.py
  10. 19 0
      coin/billing/migrations/0008_auto_20170802_2021.py
  11. 54 0
      coin/billing/migrations/0009_new_billing_system_schema.py
  12. 72 0
      coin/billing/migrations/0010_new_billing_system_data.py
  13. 362 29
      coin/billing/models.py
  14. 4 0
      coin/billing/templates/billing/invoice.html
  15. 18 3
      coin/billing/templates/billing/invoice_pdf.html
  16. 111 1
      coin/billing/tests.py
  17. 21 0
      coin/isp_database/migrations/0011_auto_20170227_0029.py
  18. 15 0
      coin/isp_database/migrations/0013_merge.py
  19. 151 0
      coin/isp_database/migrations/0014_auto_20170802_2021.py
  20. 59 32
      coin/isp_database/models.py
  21. 105 55
      coin/members/admin.py
  22. 10 7
      coin/members/autocomplete_light_registry.py
  23. 50 4
      coin/members/forms.py
  24. 2 1
      coin/members/management/commands/call_for_membership_fees.py
  25. 46 3
      coin/members/management/commands/members_email.py
  26. 19 0
      coin/members/migrations/0014_member_balance.py
  27. 19 0
      coin/members/migrations/0014_member_send_membership_fees_email.py
  28. 19 0
      coin/members/migrations/0015_auto_20170824_2308.py
  29. 15 0
      coin/members/migrations/0016_merge.py
  30. 25 0
      coin/members/migrations/0016_rowlevelpermission.py
  31. 15 0
      coin/members/migrations/0017_merge.py
  32. 74 3
      coin/members/models.py
  33. 18 8
      coin/members/templates/members/detail.html
  34. 28 0
      coin/members/templates/members/invoices.html
  35. 19 0
      coin/members/tests.py
  36. 31 6
      coin/members/views.py
  37. 27 2
      coin/offers/admin.py
  38. 0 19
      coin/offers/management/commands/subscribers_email.py
  39. 19 0
      coin/offers/migrations/0007_offersubscription_comments.py
  40. 21 0
      coin/offers/models.py
  41. 18 7
      coin/settings_base.py
  42. 154 0
      doc/user/permissions.md
  43. 47 2
      hardware_provisioning/admin.py
  44. 1 1
      hardware_provisioning/app.py
  45. 19 0
      hardware_provisioning/migrations/0014_auto_20170422_1847.py
  46. 31 0
      hardware_provisioning/migrations/0015_auto_20170802_1701.py
  47. 35 0
      hardware_provisioning/migrations/0016_auto_20170802_2021.py
  48. 19 0
      hardware_provisioning/migrations/0017_item_deployed.py
  49. 26 8
      hardware_provisioning/models.py
  50. 1 1
      requirements.txt
  51. 5 5
      vpn/admin.py
  52. 23 0
      vpn/migrations/0002_auto_20170802_2021.py
  53. 2 1
      vpn/models.py

+ 2 - 1
.gitignore

@@ -13,4 +13,5 @@ coin/settings_local.py
 .idea
 venv
 /coin.sqlite3
-.cache/
+.cache/
+.vagrant/

+ 74 - 7
README.md

@@ -79,18 +79,58 @@ See the end of this README for a reference of available configuration settings.
 Database
 --------
 
-At this point, you should setup your database. The default setting
-uses SQLite but some features will not be available, namely:
+At this point, you should setup your database. You have two options.
+
+### With PostgreSQL (for developpement), recomended
+
+The official database for coin is postgresql.
+
+To ease developpement, a postgresql virtual-machine recipe is provided
+through [vagrant](https://vagrantup.com).
+
+
+**Note: Vagrant is intended for developpement only and is totaly unsafe for a
+production setup**.
+
+Install requirements:
+
+    sudo apt install virtualbox vagrant
+
+Then, to boot and configure your dev VM:
+
+    vagrant up
+
+Default settings target that vagrant+postgreSQL setup, so, you don't have to
+change any setting.
+
+
+### With SQLite
+
+SQLite setup may be simpler, but some features will not be available, namely:
 
 - automatic allocation of IP subnets (needs proper subnet implementation in
   the database)
 - sending automated emails to remind of expiring membership fee
   (needs aggregation on date fields, see Django doc)
 
-If you want to use those features, you will need to setup a PostgreSQL
-database.
-
-For more information on the database setup, see https://www.illyse.org/projects/ils-si/wiki/Mise_en_place_environnement_de_dev
+To use sqlite instead of PostgreSQL, you have
+to [override local settings](#settings) with someting like:
+
+```python
+DATABASES = {
+    # Base de donnée du SI
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': 'coin.sqlite3',
+        'USER': '', # Not needed for SQLite
+        'PASSWORD': '', # Not needed for SQLite
+        'HOST': '',  # Empty for localhost through domain sockets
+        'PORT': '',  # Empty for default
+    },
+}
+```
+
+### For both PostgreSQL and SQLite
 
 The first time, you need to create the database, create a superuser, and
 import some base data to play with:
@@ -147,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
@@ -161,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
 =============
 
@@ -299,6 +344,28 @@ List of available settings in your `settings_local.py` file.
 - `ACCOUNT_ACTIVATION_DAYS` : All account with unvalidated email will be deleted after X days
 - `MEMBERSHIP_REFERENCE` : Template string to display the label the member should indicates for the bank transfer, default: "ADH-{{ user.pk }}"
 - `MEMBER_DEFAULT_COTISATION` : Default membership fee, if you have a more complex membership fees policy, you could overwrite templates
+- `PAYMENT_DELAY`: Payment delay in days for issued invoices ( default is 30 days which is the default in french law)
+- `MEMBER_CAN_EDIT_PROFILE`: Allow members to edit their profiles
+
+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
 ================

+ 53 - 0
Vagrantfile

@@ -0,0 +1,53 @@
+# -*- mode: ruby -*-
+# vi: set ft=ruby :
+
+Vagrant.configure("2") do |config|
+
+  config.vm.box = 'debian/jessie64'
+  config.vm.host_name = 'postgresql'
+
+  config.vm.provider "virtualbox" do |v|
+    v.customize ["modifyvm", :id, "--memory", 512]
+  end
+
+  config.vm.network "forwarded_port", guest: 5432, host: 15432
+
+  config.vm.provision "shell", privileged: true, inline: <<-SHELL
+    APP_DB_USER=coin
+    APP_DB_NAME=coin
+    APP_DB_PASS=coin
+
+    PG_VERSION=9.4
+    PG_CONF="/etc/postgresql/$PG_VERSION/main/postgresql.conf"
+    PG_HBA="/etc/postgresql/$PG_VERSION/main/pg_hba.conf"
+
+    apt-get -y update
+    apt-get install -y postgresql
+
+    # Edit postgresql.conf to change listen address to '*':
+    sed -i "s/#listen_addresses = 'localhost'/listen_addresses = '*'/" "$PG_CONF"
+
+    # Append to pg_hba.conf to add password auth:
+    echo "host    all             all             all                     md5" >> "$PG_HBA"
+
+    cat << EOF | su - postgres -c psql
+    -- Cleanup, if required
+    DROP DATABASE IF EXISTS $APP_DB_NAME;
+    DROP USER IF EXISTS $APP_DB_USER;
+
+    -- Create the database user:
+    CREATE USER $APP_DB_USER WITH PASSWORD '$APP_DB_PASS';
+    -- Allow db creation (usefull for unit testing)
+    ALTER USER $APP_DB_USER CREATEDB;
+
+    -- Create the database:
+    CREATE DATABASE $APP_DB_NAME WITH OWNER=$APP_DB_USER
+                                  LC_COLLATE='en_US.utf8'
+                                  LC_CTYPE='en_US.utf8'
+                                  ENCODING='UTF8'
+                                  TEMPLATE=template0;
+EOF
+
+    systemctl restart postgresql
+    SHELL
+end

+ 76 - 7
coin/billing/admin.py

@@ -5,10 +5,10 @@ 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 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
@@ -64,14 +64,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 +133,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 +169,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 +188,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)

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

@@ -0,0 +1,338 @@
+# -*- 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
+
+# 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=[ "DON", "MECENAT", "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.")
+
+        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")
+
+        idregex = re.compile(ID_REGEX)
+
+        for payment in payments:
+
+            payment_label = payment["label"]
+
+            # 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:
+                matches = re.compile(r"(?i)(\b|_)"+re.escape(member.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
+
+                matches = re.compile(r"(?i)(\b|_)"+re.escape(str(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:
+                    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 = str(member.last_name)
+                usernamematch = str(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)
+

+ 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)
+

+ 20 - 0
coin/billing/migrations/0005_auto_20170608_2213.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import coin.utils
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0004_auto_20161230_1803'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoice',
+            name='date_due',
+            field=models.DateField(default=coin.utils.end_of_month, help_text='Le d\xe9lai de paiement sera fix\xe9 \xe0 30 jours \xe0 la validation si laiss\xe9 vide', null=True, verbose_name="date d'\xe9ch\xe9ance de paiement", blank=True),
+        ),
+    ]

+ 19 - 0
coin/billing/migrations/0006_auto_20170608_2305.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', '0005_auto_20170608_2213'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoice',
+            name='date_due',
+            field=models.DateField(help_text='Le d\xe9lai de paiement sera fix\xe9 \xe0 20 jours \xe0 la validation si laiss\xe9 vide', null=True, verbose_name="date d'\xe9ch\xe9ance de paiement", blank=True),
+        ),
+    ]

+ 19 - 0
coin/billing/migrations/0007_auto_20170801_1530.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', '0006_auto_20170608_2305'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoice',
+            name='date_due',
+            field=models.DateField(help_text='Le d\xe9lai de paiement sera fix\xe9 \xe0 30 jours \xe0 la validation si laiss\xe9 vide', null=True, verbose_name="date d'\xe9ch\xe9ance de paiement", blank=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),
+    ]

+ 362 - 29
coin/billing/models.py

@@ -2,23 +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):
@@ -28,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
@@ -104,11 +113,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')
     )
 
@@ -125,9 +135,9 @@ class Invoice(models.Model):
         default=datetime.date.today, null=True, verbose_name='date',
         help_text='Cette date sera définie à la date de validation dans la facture finale')
     date_due = models.DateField(
-        default=end_of_month,
-        null=True,
-        verbose_name="date d'échéance de paiement")
+        null=True, blank=True,
+        verbose_name="date d'échéance de paiement",
+        help_text='Le délai de paiement sera fixé à {} jours à la validation si laissé vide'.format(settings.PAYMENT_DELAY))
     member = models.ForeignKey(Member, null=True, blank=True, default=None,
                                related_name='invoices',
                                verbose_name='membre',
@@ -137,6 +147,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)
@@ -156,15 +169,18 @@ class Invoice(models.Model):
         return total.quantize(Decimal('0.01'))
     amount.short_description = 'Montant'
 
+    def amount_before_tax(self):
+        total = Decimal('0.0')
+        for detail in self.details.all():
+            total += detail.amount
+        return total.quantize(Decimal('0.01'))
+    amount_before_tax.short_description = 'Montant HT'
+
     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):
@@ -196,11 +212,22 @@ class Invoice(models.Model):
         and generate the pdf
         """
         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 %s validated as invoice %s. "
+                            "(Total amount : %f ; Member : %s)"
+                            % (old_number, self.number, 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
@@ -208,17 +235,76 @@ 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)
 
+    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 %s to pay invoice %s"
+                           % (str(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)
@@ -269,6 +355,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,
@@ -276,24 +367,266 @@ 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 %f from payment %s to invoice %s"
+                            % (float(amount_to_allocate), str(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 %s has been paid and is now closed"
+                                % 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 %s par %s' \
+                    % (self.amount, str(self.date), self.member)
+        else:
+            return 'Paiment de %0.2f€ le %s' \
+                    % (self.amount, str(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()
+
+    accounting_log.info("Updating accounting for member %s ..."
+                        % str(member))
+    accounting_log.info("Member %s current balance is %f ..."
+                        % (str(member), float(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 %s new balance is %f"
+                        % (str(member),  float(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 %s. No invoice/payment "
+                            "reconciliation needed.)."
+                            % str(member))
+        return
+    elif active_invoices == []:
+        accounting_log.info("(No active invoice for %s. No invoice/payment "
+                            "reconciliation needed.)."
+                            % str(member))
+        return
+
+    accounting_log.info("Initiating reconciliation between "
+                        "invoice and payments for %s" % str(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 %s" % str(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 %s (Member: %s)."
+                            % ('DRAFT-{}'.format(instance.pk), instance.member))
+    else:
+        if not instance.validated:
+            accounting_log.info("Updating draft invoice %s (Member: %s)."
+                    % (instance.number, instance.member))
+        else:
+            accounting_log.info("Updating invoice %s (Member: %s, Total amount: %s, Amount paid: %s)."
+                    % (instance.number, instance.member, instance.amount(), instance.amount_paid() ))
+
+@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 %s ..." % invoice.number)
+        invoice.status = "open"
+        invoice.save()
+
+
+@receiver(post_delete, sender=Payment)
+def payment_deleted(sender, instance, **kwargs):
+
+    accounting_log.info("Deleted payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
+                        % (instance.pk, instance.date, instance.member,
+                            instance.amount, instance.label))
+
+    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()
+
+

+ 4 - 0
coin/billing/templates/billing/invoice.html

@@ -37,6 +37,10 @@
     </tbody>
 </table>
 
+<p>
+  Facture à payer avant le {{ invoice.date_due }}.
+</p>
+
 <h3>Règlement</h3>
 
 {% if invoice.payments.exists %}

+ 18 - 3
coin/billing/templates/billing/invoice_pdf.html

@@ -82,6 +82,7 @@
   .cell-label {width: 70%;}
   .cell-quantity {width: 5%;}
   .cell-amount {width: 10%;}
+  .cell-tax {width: 5%;}
   .cell-total {width: 15%;}
 
   /* details cell style */
@@ -91,8 +92,10 @@
   .cell-quantity {
     text-align: center;
   }
-  .cell--money {
+  .cell--money, 
+  .cell-tax {
     text-align: right;
+    white-space: nowrap;
   }
 
   .cell-label p + p {
@@ -158,7 +161,8 @@
       <tr>
         <th class="cell-label cell--empty"></th>
         <th class="cell-quantity">Quantité</th>
-        <th class="cell-amount cell--money">PU</th>
+        <th class="cell-amount cell--money">PU (HT)</th>
+        <th class="cell-label cell-tax">TVA</th>
         <th class="cell-total cell--money">Total</th>
       </tr>
     </thead>
@@ -181,16 +185,27 @@
         </td>
         <td class="cell-quantity">{{ detail.quantity }}</td>
         <td class="cell-amount cell--money">{{ detail.amount }}€</td>
+        <td class="cell-tax">{{ detail.tax }}%</td>
         <td class="cell-total cell--money">{{ detail.total }}€</td>
       </tr>
       {% endfor %}
+      
       <tr>
         <td class="cell-result cell--empty"></td>
-        <td class="cell-result result-label" colspan="2">Total TTC</td>
+        <td class="result-label " colspan="3">Total HT</td>
+        <td class="cell--money ">{{ invoice.amount_before_tax }}€</td>
+      </tr>
+      <tr>
+        <td class="cell-result cell--empty"></td>
+        <td class="cell-result result-label" colspan="3">Total TTC</td>
         <td class="cell-result result-total cell--money">{{ invoice.amount }}€</td>
       </tr>
+       
     </tbody>
   </table>
+  <p>
+    Facture à payer avant le {{ invoice.date_due }}.
+  </p>
 
   <div id="paiements">
   {% include "billing/payment_howto.html" %}

+ 111 - 1
coin/billing/tests.py

@@ -9,7 +9,7 @@ from django.test import TestCase, Client
 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
@@ -115,6 +115,78 @@ class BillingInvoiceCreationTests(TestCase):
         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()
+
+        invoice.details.create(label=self.offer.name,
+                               amount=100,
+                               offersubscription=self.subscription,
+                               period_from=datetime.date(2014, 1, 1),
+                               period_to=datetime.date(2014, 3, 31),
+                               tax=0)
+
+        invoice.details.create(label=self.offer.name,
+                               amount=10,
+                               offersubscription=self.subscription,
+                               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):
+        invoice = Invoice(member=self.member)
+        invoice.save()
+
+        invoice.details.create(label=self.offer.name,
+                               amount=100,
+                               offersubscription=self.subscription,
+                               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')
+        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')
+
+        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):
+        invoice = Invoice(member=self.member)
+        invoice.save()
+
+        invoice.details.create(label=self.offer.name,
+                               amount=100,
+                               offersubscription=self.subscription,
+                               period_from=datetime.date(2014, 1, 1),
+                               period_to=datetime.date(2014, 3, 31),
+                               tax=0)
+
+        invoice.details.create(label=self.offer.name,
+                               amount=10,
+                               offersubscription=self.subscription,
+                               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):
         """
         Test qu'une offre non facturable n'est pas prise en compte
@@ -264,3 +336,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()
+
+

+ 21 - 0
coin/isp_database/migrations/0011_auto_20170227_0029.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import re
+import django.core.validators
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('isp_database', '0010_ispinfo_phone_number'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='chatroom',
+            name='url',
+            field=models.CharField(max_length=256, verbose_name='URL', validators=[django.core.validators.RegexValidator(regex=re.compile('(?P<protocol>\\w+://)(?P<server>[\\w\\.]+)/(?P<channel>.*)'), message='Enter a value of the form  <proto>://<server>/<channel>')]),
+        ),
+    ]

+ 15 - 0
coin/isp_database/migrations/0013_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 = [
+        ('isp_database', '0012_auto_20170328_2257'),
+        ('isp_database', '0011_auto_20170227_0029'),
+    ]
+
+    operations = [
+    ]

+ 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

+ 105 - 55
coin/members/admin.py

@@ -5,7 +5,8 @@ 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.db.models.query import QuerySet
@@ -13,9 +14,9 @@ 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,23 +36,50 @@ class MembershipFeeInline(admin.TabularInline):
 class OfferSubscriptionInline(admin.TabularInline):
     model = OfferSubscription
     extra = 0
-    readonly_fields = ('get_subscription_reference', 'subscription_date', 'resign_date',
-                       'commitment', 'offer', 'show_change_link')
 
-    # FIXME: Workaround en attendant la migration vers Django >=1.8
-    # À remplacer par InlineModelAdmin.show_change_link = True
-    def show_change_link(self, obj=None):
-        url = reverse('admin:%s_%s_change' % (obj._meta.app_label,
-                                              obj._meta.model_name),
-                      args=[obj.id])
-        return format_html(u'<a href="{}">Éditer</a>', url)
-    show_change_link.short_description = 'Éditer ?'
+    writable_fields = ('subscription_date', 'resign_date', 'commitment', 'offer')
+    all_fields = ('get_subscription_reference',) + writable_fields
 
-    def has_add_permission(self, request, obj=None):
-        return False
+    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
+
+    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):
@@ -65,45 +93,49 @@ 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',
+    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'))}),
-        (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',
-            ('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:
+            return (
+                ('Adhérent', {'fields': (
+                    ('status', 'date_joined', 'resign_date'),
+                    'type',
+                    ('first_name', 'last_name', 'nickname'),
+                    'organization_name',
+                    'comments',
+                    'balance' # XXX we shouldn't need this, the default value should be used
+                )}),
+                coord_fieldset,
+                auth_fieldset,
+                perm_fieldset,
+                (None, {'fields': ('date_last_call_for_membership_fees_email',)})
+            )
+        else:
+            return (
+                ('Adhérent', {'fields': (
+                    ('status', 'date_joined'),
+                    'type',
+                    ('first_name', 'last_name', 'nickname'),
+                    'organization_name',
+                    'comments',
+                    'balance')}),
+                coord_fieldset,
+                auth_fieldset,
+                perm_fieldset
+            )
 
     radio_fields = {"type": admin.HORIZONTAL}
 
@@ -111,16 +143,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')
@@ -218,7 +261,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
 
@@ -52,20 +53,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')
@@ -81,5 +80,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 = [
+    ]

+ 74 - 3
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
 
@@ -428,7 +453,7 @@ class MembershipFee(models.Model):
                                     verbose_name='date du paiement')
 
     def clean(self):
-        if self.end_date is None:
+        if self.start_date is not None and self.end_date is None:
             self.end_date = self.start_date + datetime.timedelta(364)
 
     def __unicode__(self):
@@ -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>
 

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

@@ -1,6 +1,9 @@
 {% extends "base.html" %}
 
 {% block content %}
+
+<h2>Balance : {{ balance|floatformat }} €</h2>
+
 <h2>Mes factures</h2>
 
 <table id="member_invoices" class="full-width">
@@ -28,6 +31,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" %}

+ 19 - 0
coin/members/tests.py

@@ -509,3 +509,22 @@ class TestValidators(TestCase):
         chatroom_url_validator('irc://irc.example.com/#chan')
         with self.assertRaises(ValidationError):
             chatroom_url_validator('http://#faimaison@irc.geeknode.org')
+
+
+class MembershipFeeTests(TestCase):
+    def test_mandatory_start_date(self):
+        member = Member(first_name='foo', last_name='foo', password='foo', email='foo')
+        member.save()
+
+        # If there is no start_date clean_fields() should raise an
+        # error but not clean().
+        membershipfee = MembershipFee(member=member)
+        self.assertRaises(ValidationError, membershipfee.clean_fields)
+        self.assertIsNone(membershipfee.clean())
+
+        # If there is a start_date, everything is fine.
+        membershipfee = MembershipFee(member=member, start_date=date.today())
+        self.assertIsNone(membershipfee.clean_fields())
+        self.assertIsNone(membershipfee.clean())
+
+        member.delete()

+ 31 - 6
coin/members/views.py

@@ -1,10 +1,11 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-from django.shortcuts import render
+from django.template import RequestContext
+from django.shortcuts import render_to_response, render
 from django.contrib.auth.decorators import login_required
 from django.conf import settings
-
+from forms import PersonMemberChangeForm, OrganizationMemberChangeForm
 
 @login_required
 def index(request):
@@ -15,9 +16,28 @@ def index(request):
 
 @login_required
 def detail(request):
+
     membership_info_url = settings.MEMBER_MEMBERSHIP_INFO_URL
-    return render(request, 'members/detail.html',
-                  {'membership_info_url': membership_info_url})
+    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
@@ -32,10 +52,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(request, 'members/invoices.html',
-                  {'invoices': invoices})
+    return render_to_response('members/invoices.html',
+                              {'balance' : balance, 
+                               'invoices': invoices, 
+                               'payments': payments},
+                              context_instance=RequestContext(request))
 
 
 @login_required

+ 27 - 2
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,\
@@ -42,9 +44,32 @@ class OfferSubscriptionAdmin(admin.ModelAdmin):
                 'offer',
                 'subscription_date',
                 'commitment',
-                'resign_date'
+                '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)

+ 19 - 0
coin/offers/migrations/0007_offersubscription_comments.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('offers', '0006_offer_reference'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='offersubscription',
+            name='comments',
+            field=models.TextField(help_text="Commentaires libres (informations sp\xe9cifiques concernant l'abonnement)", verbose_name='commentaires', blank=True),
+        ),
+    ]

+ 21 - 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
@@ -125,6 +143,9 @@ class OfferSubscription(models.Model):
                                      help_text='en mois',
                                      validators=[MinValueValidator(0)],
                                      default=0)
+    comments = models.TextField(blank=True, verbose_name='commentaires',
+                                help_text="Commentaires libres (informations"
+                                " spécifiques concernant l'abonnement)")
     member = models.ForeignKey('members.Member', verbose_name='membre')
     offer = models.ForeignKey('Offer', verbose_name='offre')
 

+ 18 - 7
coin/settings_base.py

@@ -19,14 +19,14 @@ ADMINS = (
 MANAGERS = ADMINS
 
 DATABASES = {
-    # Base de donnée du SI
+    # Database hosted on vagant test box
     'default': {
-        'ENGINE': 'django.db.backends.sqlite3',
-        'NAME': 'coin.sqlite3',
-        'USER': '', # Not needed for SQLite
-        'PASSWORD': '', # Not needed for SQLite
-        'HOST': '',  # Empty for localhost through domain sockets
-        'PORT': '',  # Empty for default
+        'ENGINE': 'django.db.backends.postgresql_psycopg2',
+        'NAME': 'coin',
+        'USER': 'coin',
+        'PASSWORD': 'coin',
+        'HOST': 'localhost',  # Empty for localhost through domain sockets
+        'PORT': '15432',  # Empty for default
     },
 }
 
@@ -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',
+        }
     }
 }
 
@@ -254,6 +260,9 @@ SUBSCRIPTION_REFERENCE = 'REF-{subscription.offer.reference}-{subscription.pk}'
 # transfer
 MEMBERSHIP_REFERENCE = "ADH-{user.pk}"
 
+# Payment delay in days
+PAYMENT_DELAY = 30
+
 # Reset session if cookie older than 2h.
 SESSION_COOKIE_AGE = 7200
 
@@ -272,3 +281,5 @@ REGISTRATION_OPEN = False
 # All account with unvalidated email will be deleted after X days
 ACCOUNT_ACTIVATION_DAYS = 7
 
+# Member can edit their own data
+MEMBER_CAN_EDIT_PROFILE = False

+ 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).

+ 47 - 2
hardware_provisioning/admin.py

@@ -5,9 +5,12 @@ from __future__ import unicode_literals
 
 from django.contrib import admin
 from django.contrib.auth import get_user_model
+from django.forms import ModelChoiceField
 from django.utils import timezone
+import autocomplete_light
 
 from .models import ItemType, Item, Loan, Storage
+import coin.members.admin
 
 
 User = get_user_model()
@@ -55,7 +58,7 @@ class AvailabilityFilter(admin.SimpleListFilter):
 class ItemAdmin(admin.ModelAdmin):
     list_display = (
         'designation', 'type', 'mac_address', 'serial', 'owner',
-        'buy_date', 'is_available')
+        'buy_date', 'deployed', 'is_available')
     list_filter = (
         AvailabilityFilter, 'type__name', 'storage',
         'buy_date', OwnerFilter)
@@ -63,8 +66,11 @@ class ItemAdmin(admin.ModelAdmin):
         '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 +125,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', '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 +146,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 +166,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')]

+ 19 - 0
hardware_provisioning/migrations/0014_auto_20170422_1847.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', '0013_auto_20161110_2246'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='item',
+            name='buy_date',
+            field=models.DateField(null=True, verbose_name='date d\u2019achat', blank=True),
+        ),
+    ]

+ 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'),
+        ),
+    ]

+ 26 - 8
hardware_provisioning/models.py

@@ -40,20 +40,22 @@ 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)')
-    buy_date = models.DateField(verbose_name='date d’achat')
+        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,
         verbose_name='Propriétaire',
         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,15 +83,21 @@ 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'
 
@@ -128,9 +136,19 @@ class Loan(models.Model):
         return 'prêt de {item} à {user}'.format(
             item=self.item, user=self.user)
 
+    def get_mac_and_serial(self):
+        return self.item.get_mac_and_serial()
+
+    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'

+ 1 - 1
requirements.txt

@@ -3,7 +3,7 @@ psycopg2==2.5.2
 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

+ 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):