Browse Source

Merge branch 'arnprod' of https://code.ffdn.org/ARN/coin into arnprod

root 7 years ago
parent
commit
4b609a1487
52 changed files with 2015 additions and 509 deletions
  1. 6 1
      README.md
  2. 77 0
      arn/templates/billing/invoice.html
  3. 235 0
      arn/templates/billing/invoice_pdf.html
  4. 25 0
      arn/templates/billing/payment_howto.html
  5. 29 6
      coin/billing/admin.py
  6. 12 6
      coin/billing/create_subscriptions_invoices.py
  7. 31 3
      coin/billing/management/commands/charge_subscriptions.py
  8. 28 11
      coin/billing/management/commands/import_payments_from_csv.py
  9. 2 6
      coin/members/membershipfee_filter.py
  10. 1 2
      coin/billing/migrations/0010_new_billing_system_data.py
  11. 19 0
      coin/billing/migrations/0011_auto_20180414_2250.py
  12. 96 0
      coin/billing/migrations/0012_auto_20180415_1502.py
  13. 63 0
      coin/billing/migrations/0013_auto_20180415_0413.py
  14. 54 0
      coin/billing/migrations/0014_auto_20180415_1814.py
  15. 18 0
      coin/billing/migrations/0015_remove_payment_invoice.py
  16. 19 0
      coin/billing/migrations/0016_auto_20180415_2208.py
  17. 19 0
      coin/billing/migrations/0017_auto_20180416_0103.py
  18. 34 0
      coin/billing/migrations/0018_auto_20180416_0124.py
  19. 262 124
      coin/billing/models.py
  20. 1 1
      coin/billing/templates/admin/billing/invoice/change_form.html
  21. 1 1
      coin/billing/templates/billing/invoice.html
  22. 176 7
      coin/billing/tests.py
  23. 2 2
      coin/billing/urls.py
  24. 1 1
      coin/billing/utils.py
  25. 105 71
      coin/members/admin.py
  26. 10 7
      coin/members/autocomplete_light_registry.py
  27. 2 1
      coin/members/management/commands/call_for_membership_fees.py
  28. 46 3
      coin/members/management/commands/members_email.py
  29. 19 0
      coin/members/migrations/0014_member_send_membership_fees_email.py
  30. 19 0
      coin/members/migrations/0015_auto_20170824_2308.py
  31. 15 0
      coin/members/migrations/0016_merge.py
  32. 25 0
      coin/members/migrations/0016_rowlevelpermission.py
  33. 15 0
      coin/members/migrations/0017_merge.py
  34. 30 0
      coin/members/migrations/0018_auto_20180414_2250.py
  35. 21 0
      coin/members/migrations/0019_auto_20180415_1814.py
  36. 72 48
      coin/members/models.py
  37. 4 4
      coin/members/templates/members/invoices.html
  38. 1 158
      coin/members/tests.py
  39. 4 2
      coin/members/views.py
  40. 26 1
      coin/offers/admin.py
  41. 0 19
      coin/offers/management/commands/subscribers_email.py
  42. 18 0
      coin/offers/models.py
  43. 4 1
      coin/settings_base.py
  44. 154 0
      doc/user/permissions.md
  45. 50 2
      hardware_provisioning/admin.py
  46. 19 0
      hardware_provisioning/migrations/0017_item_deployed.py
  47. 36 6
      hardware_provisioning/models.py
  48. 46 11
      hardware_provisioning/tests.py
  49. 19 0
      housing/migrations/0002_auto_20180414_2250.py
  50. 6 4
      requirements.txt
  51. 19 0
      vpn/migrations/0004_auto_20180414_2250.py
  52. 19 0
      vps/migrations/0004_auto_20180414_2250.py

+ 6 - 1
README.md

@@ -187,7 +187,8 @@ Some useful administration commands are available via `manage.py`.
 per line.  This may be useful to automatically feed a mailing list software.
 Note that membership is based on the `status` field of users, not on
 membership fees.  That is, even if a member has forgot to renew his or her
-membership fee, his or her address will still show up in this list.
+membership fee, his or her address will still show up in this list. More
+filters are available, see the command's help for more details.
 
 `python manage.py charge_subscriptions`: generate invoices (including a
 PDF version) for each subscriber.  You probably want to run this command
@@ -201,6 +202,10 @@ You should run this command in a cron job every day.
 `python manage.py offer_subscriptions_count`: Returns subscription count grouped
 by offer type.
 
+`python manage.py import_payments_from_csv`: Import a CSV from a bank and match
+payments with services and/or members. At the moment, this is quite specific to
+ARN workflow
+
 Configuration
 =============
 

+ 77 - 0
arn/templates/billing/invoice.html

@@ -0,0 +1,77 @@
+{% extends "base.html" %}
+
+{% block content %}
+<div class="row">
+    <div class="large-8 columns">
+        <h2>Facture N°{{ invoice.number }}</h2>
+        <p>Émise le {{ invoice.date }}</p>
+    </div>
+    <div class="large-4 columns">
+        {% if invoice.validated %}<a href="{% url 'billing:bill_pdf' id=invoice.number %}" target="_blank" class="button expand"><i class="fa fa-file-pdf-o"></i> Télécharger en PDF</a>{% endif %}
+    </div>
+</div>
+
+<table id="invoice_details" class="invoice-table full-width">
+    <thead>
+        <tr>
+            <th></th>
+            <th>Quantité</th>
+            <th>PU</th>
+            <th class="total">Total TTC</th>
+        </tr>
+    </thead>
+    <tbody>
+        {% for detail in invoice.details.all %}
+        <tr>
+            <td>{{ detail.label }}
+                {% if detail.period_from and detail.period_to %}<br/><span class="period">Pour la période du {{ detail.period_from }} au {{ detail.period_to }}{% endif %}</span></td>
+            <td>{{ detail.quantity }}</td>
+            <td>{{ detail.amount }}€</td>
+            <td class="total">{{ detail.total }}€</td>
+        </tr>
+        {% endfor %}
+        <tr class="total">
+            <td class="" colspan="3">Total TTC</td>
+            <td class="total">{{ invoice.amount }}€</td>
+        </tr>
+    </tbody>
+</table>
+
+<p>
+  Facture à payer avant le {{ invoice.date_due }}.
+</p>
+
+<h3>Règlement</h3>
+
+{% if invoice.payments.exists %}
+    <table id="invoice-payments" class="invoice-table full-width">
+        <thead>
+            <tr>
+                <th>Type de paiement</th>
+                <th>Date</th>
+                <th class="total">Montant</th>
+            </tr>
+        </thead>
+        <tbody>
+            {% for payment in invoice.payments.all %}
+            <tr class="payment">
+                <td>{{ payment.get_payment_mean_display }}</td>
+                <td>{{ payment.date }}</td>
+                <td class="total">-{{ payment.amount }}€</td>
+            </tr>
+            {% endfor %}
+            <tr class="total">
+                <td class="" colspan="2">Reste à payer</td>
+                <td class="total">{{ invoice.amount_remaining_to_pay }}€</td>
+            </tr>
+        </tbody>
+    </table>
+{% endif %}
+
+{% if invoice.amount_remaining_to_pay > 0 %}
+    <div id="payment-howto" class="panel">
+        {% include "billing/payment_howto.html" %}
+    </div>
+{% endif %}
+
+{% endblock %}

+ 235 - 0
arn/templates/billing/invoice_pdf.html

@@ -0,0 +1,235 @@
+{% load static isptags %}
+<html>
+<head>
+  <title>Facture N°{{ invoice.number }}</title>
+
+  <style>
+  @page {
+    margin: 0; padding: 40pt;
+  }
+
+  html {
+    box-sizing: border-box;
+  }
+  *, *:before, *:after {
+    box-sizing: inherit;
+  }
+
+  body {
+    font-size: 9pt;
+    font-family: sans-serif;
+    color: #111;
+    padding: 0;
+  }
+  a {
+    color: #111;
+    text-decoration: none;
+  }
+
+  p {
+    margin: 0;
+  }
+  p + p {
+    margin-top: 10pt;
+  }
+  table {
+    border-collapse: collapse;
+    width: 100%;
+    margin: 40pt 0;
+  }
+
+  h1 {
+    font-size: 12pt;
+  }
+
+  #header {
+    margin: 0 0 60pt 0;
+    border: none;
+  }
+
+  #header .logo {
+    height: 50pt;
+    margin: 0;
+  }
+
+  #header .header-left {
+    text-align: left;
+  }
+
+  #header .header-right {
+    text-align: right;
+  }
+
+  footer {
+    position: fixed;
+    bottom: 0;
+    width: 100%;
+  }
+
+  footer .logo {
+    height: 20pt;
+  }
+
+  #coordonnees {}
+
+  #coordonnees td {
+    width: 50%;
+    vertical-align: top;
+  }
+
+  #details {}
+
+  #details th,
+  #details td {
+    padding: 5pt;
+    border:1px solid #ddd;
+  }
+  #details th.cell--empty,
+  #details td.cell--empty {border: 0;}
+
+  /* details cell layout */
+  .cell-label {width: 70%;}
+  .cell-quantity {width: 5%;}
+  .cell-amount {width: 10%;}
+  .cell-tax {width: 5%;}
+  .cell-total {width: 15%;}
+
+  /* details cell style */
+  .cell-result {
+    font-weight: bold;
+  }
+  .cell-quantity {
+    text-align: center;
+  }
+  .cell--money,
+  .cell-tax {
+    text-align: right;
+    white-space: nowrap;
+  }
+
+  .cell-label p + p {
+    margin-top: 5pt;
+  }
+  .period {
+    color:#888;
+  }
+
+  #paiements {
+    margin-top: 40pt;
+    background-color: #f0f0f0;
+    padding: 10pt;
+    font-size: x-small;
+  }
+
+  footer {
+    font-size: xx-small;
+  }
+  .pagination {
+    float: right;
+  }
+  </style>
+</head>
+<body>
+
+  <table id="header">
+      <tr>
+          <td class="header-left">
+              <img class="logo" src="{{ branding.logoURL }}" />
+          </td>
+          <td class="header-right">
+              <p>Facture N°{{ invoice.number }}</p>
+              <p>Date : {{ invoice.date }}</p>
+          </td>
+      </tr>
+  </table>
+
+  <table id="coordonnees">
+    <tr>
+      <td id="coordonnees_isp">
+        <p>
+        {% multiline_isp_addr branding %}
+        </p>
+        <p>
+        <a href="mailto:{{ branding.email }}">{{ branding.email }}</a><br/>
+        <a href="{{ branding.website }}">{{ branding.website }}</a><br />
+        {{ branding.phone_number }}<br />
+        SIRET : {{ branding.registeredoffice.siret }}
+        </p>
+      </td>
+      <td id="coordonnees_client">
+        <p>
+        <strong>Facturé à</strong><br/>
+        {% with member=invoice.member %}
+        {{ member.last_name }} {{ member.first_name }}<br />
+        {% if member.organization_name != "" %}{{ member.organization_name }}<br />{% endif %}
+        {% if member.address %}{{member.address}}<br />{% endif %}
+        {% if member.postal_code and member.city %}
+        {{ member.postal_code }} {{ member.city }}
+        {% endif %}
+        {% endwith %}
+        </p>
+      </td>
+    </tr>
+  </table>
+
+  <table id="details" repeat="1">
+    <thead>
+      <tr>
+        <th class="cell-label cell--empty"></th>
+        <th class="cell-quantity">Quantité</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>
+    <tbody>
+      {% for detail in invoice.details.all %}
+      <tr>
+        <td class="cell-label">
+          <p>
+          {{ detail.label }}
+          {% if detail.offersubscription %}
+            <br/>
+            <span class="subscription">{{ detail.offersubscription.offer.name }}
+            {% if detail.offersubscription.offer.reference %} ({{ detail.offersubscription.get_subscription_reference }}){% endif %}
+            </span>
+          {% endif %}
+          </p>
+          {% if detail.period_from and detail.period_to %}
+          <p class="period">Pour la période du {{ detail.period_from }} au {{ detail.period_to }}</p>
+          {% endif %}
+        </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="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>TVA non applicable - article 293 B du CGI</p>
+  <p>À payer sans escompte avant le {{ invoice.date_due }}.</p>
+
+  <div id="paiements">
+  {% include "billing/payment_howto.html" %}
+  </div>
+
+  <footer>
+    <p class="pagination"><pdf:pagenumber>/<pdf:pagecount></p>
+    <p>{{ branding.shortname|upper }} est une association de droit local alsacien-mosellan à but non lucratif.</p>
+  </footer>
+</body>
+</html>

+ 25 - 0
arn/templates/billing/payment_howto.html

@@ -0,0 +1,25 @@
+{% load isptags %}
+
+<p>
+    <strong>Merci de payer par virement bancaire</strong><br />
+    <br>
+    Titulaire du compte : {% firstof branding.shortname branding.name %}<br/>
+    IBAN : {{ branding.bankinfo.iban|pretty_iban }}<br />
+    {% if branding.bankinfo.bic %}
+    BIC : {{ branding.bankinfo.bic }}<br />
+    {% endif %}
+
+    <br>
+
+    {% if invoice %}
+    <strong>Prière de faire figurer l'une de vos reference adherent sur vos virement :</strong><br />
+    {% with member=invoice.member %}
+        ID {{ member.pk }} et ou {{ member.username }} <br />
+    {% endwith %}
+    {% endif %}
+
+    {% if member %}
+    <strong>Prière de faire figurer l'une de vos reference adherent sur vos virement :</strong><br />
+        ID {{ member.pk }} et ou {{ member.username }} <br />
+    {% endif %}
+</p>

+ 29 - 6
coin/billing/admin.py

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

+ 12 - 6
coin/billing/create_subscriptions_invoices.py

@@ -11,9 +11,9 @@ from django.core.exceptions import ObjectDoesNotExist
 from coin.offers.models import Offer, OfferSubscription
 from coin.members.models import Member
 from coin.billing.models import Invoice, InvoiceDetail
+from django.conf import settings
 
-
-def create_all_members_invoices_for_a_period(date=None):
+def create_all_members_invoices_for_a_period(date=None, antidate=False):
     """
     Pour chaque membre ayant au moins un abonnement actif, génère les factures
     en prenant la date comme premier mois de la période de facturation
@@ -27,14 +27,14 @@ def create_all_members_invoices_for_a_period(date=None):
     invoices = []
 
     for member in members:
-        invoice = create_member_invoice_for_a_period(member, date)
+        invoice = create_member_invoice_for_a_period(member, date, antidate)
         if invoice is not None:
             invoices.append(invoice)
 
     return invoices
 
 @transaction.atomic
-def create_member_invoice_for_a_period(member, date):
+def create_member_invoice_for_a_period(member, date, antidate):
     """
     Créé si nécessaire une facture pour un membre en prenant la date passée
     en paramètre comme premier mois de période. Renvoi la facture générée
@@ -140,7 +140,8 @@ def create_member_invoice_for_a_period(member, date):
                 # à la facture
                 label = offer.name
                 try:
-                    if (offer_subscription.configuration.comment):
+                    if settings.ADD_COMMENTS_IN_BILLING and \
+                    (offer_subscription.configuration.comment):
                         label += " (%s)" % offer_subscription.configuration.comment
                 except ObjectDoesNotExist:
                     pass
@@ -156,7 +157,12 @@ def create_member_invoice_for_a_period(member, date):
     if invoice.details.count() > 0:
         invoice.save()
         transaction.savepoint_commit(sid)
-        invoice.validate() # Valide la facture et génère le PDF
+        # Valide la facture et génère le PDF
+        if antidate:
+            invoice.date_due = None # (reset the due date, will automatically be redefined when validating)
+            invoice.validate(period_to)
+        else:
+            invoice.validate()
         return invoice
     else:
         transaction.savepoint_rollback(sid)

+ 31 - 3
coin/billing/management/commands/charge_subscriptions.py

@@ -1,5 +1,7 @@
 # -*- coding: utf-8 -*-
 import datetime
+
+from argparse import RawTextHelpFormatter
 from django.core.management.base import BaseCommand, CommandError
 from django.conf import settings
 
@@ -8,13 +10,39 @@ from coin.billing.create_subscriptions_invoices import create_all_members_invoic
 
 
 class Command(BaseCommand):
-    args = '[date=2011-07-04]'
+
     help = 'Create invoices for members subscriptions for date specified (or today if no date passed)'
 
+    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(
+            'date',
+            type=str,
+            help="The date for the period for which to charge subscription (e.g. 2011-07-04)"
+        )
+
+        parser.add_argument(
+            '--antidate',
+            action='store_true',
+            dest='antidate',
+            default=False,
+            help="'Antidate' invoices, in the sense that invoices won't be validated with today's date but using the date of the end of the service. Meant to be use to charge subscription from a few months in the past..."
+        )
+
+
+
     def handle(self, *args, **options):
         verbosity = int(options['verbosity'])
+        antidate = options['antidate']
+        date = options["date"]
+
         try:
-            date = datetime.datetime.strptime(args[0], '%Y-%m-%d').date()
+            date = datetime.datetime.strptime(date, '%Y-%m-%d').date()
         except IndexError:
             date = datetime.date.today()
         except ValueError:
@@ -25,7 +53,7 @@ class Command(BaseCommand):
             self.stdout.write(
                 'Create invoices for all members for the date : %s' % date)
         with respect_language(settings.LANGUAGE_CODE):
-            invoices = create_all_members_invoices_for_a_period(date)
+            invoices = create_all_members_invoices_for_a_period(date, antidate)
 
         if len(invoices) > 0 or verbosity >= 2:
             self.stdout.write(

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

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

+ 2 - 6
coin/members/membershipfee_filter.py

@@ -33,10 +33,6 @@ class MembershipFeeFilter(SimpleListFilter):
         `self.value()`.
         """
         if self.value() == 'paidup':
-            return queryset.filter(
-                membership_fees__start_date__lte=datetime.date.today,
-                membership_fees__end_date__gte=datetime.date.today)
+            return queryset.filter(id__in=[i.id for i in queryset.all() if i.is_paid_up()])
         if self.value() == 'late':
-            return queryset.filter(status='member').exclude(
-                membership_fees__start_date__lte=datetime.date.today,
-                membership_fees__end_date__gte=datetime.date.today)
+            return queryset.filter(id__in=[i.id for i in queryset.all() if not i.is_paid_up()])

+ 1 - 2
coin/billing/migrations/0010_new_billing_system_data.py

@@ -63,11 +63,10 @@ class Migration(migrations.Migration):
 
     dependencies = [
         ('billing', '0009_new_billing_system_schema'),
-        ('members', '0014_member_balance'),
+        ('members', '0016_merge'),
     ]
 
     operations = [
         migrations.RunPython(check_current_state),
         migrations.RunPython(forwards),
     ]
-

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

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

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

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

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

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

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

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

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

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

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

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

+ 19 - 0
coin/billing/migrations/0017_auto_20180416_0103.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', '0016_auto_20180415_2208'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='payment',
+            name='payment_mean',
+            field=models.CharField(default='transfer', max_length=100, null=True, verbose_name='moyen de paiement', choices=[('cash', 'Esp\xe8ces'), ('check', 'Ch\xe8que'), ('transfer', 'Virement'), ('creditnote', 'Avoir'), ('other', 'Autre')]),
+        ),
+    ]

+ 34 - 0
coin/billing/migrations/0018_auto_20180416_0124.py

@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0017_auto_20180416_0103'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoicedetail',
+            name='amount',
+            field=models.DecimalField(verbose_name='montant', max_digits=8, decimal_places=2),
+        ),
+        migrations.AlterField(
+            model_name='membershipfee',
+            name='_amount',
+            field=models.DecimalField(default=None, help_text='en \u20ac', verbose_name='montant', max_digits=8, decimal_places=2),
+        ),
+        migrations.AlterField(
+            model_name='payment',
+            name='amount',
+            field=models.DecimalField(null=True, verbose_name='montant', max_digits=8, decimal_places=2),
+        ),
+        migrations.AlterField(
+            model_name='paymentallocation',
+            name='amount',
+            field=models.DecimalField(null=True, verbose_name='montant', max_digits=8, decimal_places=2),
+        ),
+    ]

+ 262 - 124
coin/billing/models.py

@@ -5,6 +5,7 @@ import datetime
 import logging
 import uuid
 import re
+import abc
 from decimal import Decimal
 from dateutil.relativedelta import relativedelta
 
@@ -29,13 +30,116 @@ from coin.isp_database.models import ISPInfo
 accounting_log = logging.getLogger("coin.billing")
 
 
-def invoice_pdf_filename(instance, filename):
+def bill_pdf_filename(instance, filename):
     """Nom et chemin du fichier pdf à stocker pour les factures"""
     member_id = instance.member.id if instance.member else 0
     return 'invoices/%d_%s_%s.pdf' % (member_id,
                                       instance.number,
                                       uuid.uuid4())
 
+def invoice_pdf_filename(instance, filename):
+    return bill_pdf_filename(instance, filename)
+
+class Bill(models.Model):
+
+    BILL_STATUS_CHOICES = (
+        ('open', 'À payer'),
+        ('closed', 'Réglée'),
+        ('trouble', 'Litige')
+    )
+
+    status = models.CharField(max_length=50, choices=BILL_STATUS_CHOICES,
+                              default='open',
+                              verbose_name='statut')
+    date = models.DateField(
+        default=datetime.date.today, null=True, verbose_name='date',
+        help_text='Cette date sera définie à la date de validation dans le document final')
+    member = models.ForeignKey(Member, null=True, blank=True, default=None,
+                               related_name='bills',
+                               verbose_name='membre',
+                               on_delete=models.SET_NULL)
+    pdf = models.FileField(storage=private_files_storage,
+                           upload_to=bill_pdf_filename,
+                           null=True, blank=True,
+                           verbose_name='PDF')
+    @property
+    def amount(self):
+        """ Return bill amount """
+        return self.cast.amount
+    amount.fget.short_description = 'Montant'
+
+    def amount_paid(self):
+        """
+        Calcul le montant déjà payé à partir des allocations de paiements
+        """
+        return sum([a.amount for a in self.allocations.all()])
+    amount_paid.short_description = 'Montant payé'
+
+    def amount_remaining_to_pay(self):
+        """
+        Calcul le montant restant à payer
+        """
+        return self.amount - self.amount_paid()
+    amount_remaining_to_pay.short_description = 'Reste à payer'
+
+    def has_owner(self, username):
+        """
+        Check if passed username (ex gmajax) is owner of the invoice
+        """
+        return (self.member and self.member.username == username)
+
+    def generate_pdf(self):
+        """
+        Make and store a pdf file for the invoice
+        """
+        context = {"invoice": self}
+        context.update(branding(None))
+        pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
+        self.pdf.save('%s.pdf' % self.number, pdf_file)
+
+    def pdf_exists(self):
+        return (bool(self.pdf)
+                and private_files_storage.exists(self.pdf.name))
+
+#    def get_absolute_url(self):
+#        return reverse('billing:invoice', args=[self.number])
+
+    def __unicode__(self):
+        return '%s - %s - %i€' % (self.member, self.date, self.amount)
+
+    @property
+    def reference(self):
+        if hasattr(self, 'membershipfee'):
+            return 'Cotisation'
+        elif hasattr(self, 'donation'):
+            return 'Don'
+        elif hasattr(self, 'invoice'):
+            return self.invoice.number
+
+    def log_change(self, created):
+
+        if created:
+            accounting_log.info("Creating %s %s (Member: %s)."
+                                % (self.pk, self.member))
+        else:
+            accounting_log.info("Updating %s %s (Member: %s, Total amount: %s, Amount paid: %s)."
+                        % (self.pk, self.member, self.amount, self.amount_paid() ))
+
+    @property
+    def cast(bill):
+        if hasattr(bill, 'membershipfee'):
+            return bill.membershipfee
+        elif hasattr(bill, 'donation'):
+            return bill.donation
+        elif hasattr(bill, 'invoice'):
+            return bill.invoice
+    @staticmethod
+    def get_member_validated_bills(member):
+        related_fields = ['membershipfee', 'donation', 'invoice']
+        return [i.cast for i in member.bills.order_by("date") if i.cast.validated]
+
+    class Meta:
+        verbose_name = 'note'
 
 @python_2_unicode_compatible
 class InvoiceNumber:
@@ -87,7 +191,10 @@ class InvoiceNumber:
         :rtype: dict
         """
 
-        return {'{}__month'.format(field_name): date.month}
+        return {
+            '{}__month'.format(field_name): date.month,
+            '{}__year'.format(field_name): date.year
+        }
 
 
 class InvoiceQuerySet(models.QuerySet):
@@ -114,13 +221,8 @@ class InvoiceQuerySet(models.QuerySet):
             InvoiceNumber.RE_INVOICE_NUMBER))
 
 
-class Invoice(models.Model):
+class Invoice(Bill):
 
-    INVOICES_STATUS_CHOICES = (
-        ('open', 'À payer'),
-        ('closed', 'Réglée'),
-        ('trouble', 'Litige')
-    )
 
     validated = models.BooleanField(default=False, verbose_name='validée',
                                     help_text='Once validated, a PDF is generated'
@@ -128,24 +230,10 @@ class Invoice(models.Model):
     number = models.CharField(max_length=25,
                               unique=True,
                               verbose_name='numéro')
-    status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
-                              default='open',
-                              verbose_name='statut')
-    date = models.DateField(
-        default=datetime.date.today, null=True, verbose_name='date',
-        help_text='Cette date sera définie à la date de validation dans la facture finale')
     date_due = models.DateField(
         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',
-                               on_delete=models.SET_NULL)
-    pdf = models.FileField(storage=private_files_storage,
-                           upload_to=invoice_pdf_filename,
-                           null=True, blank=True,
-                           verbose_name='PDF')
 
     date_last_reminder_email = models.DateTimeField(null=True, blank=True,
                         verbose_name="Date du dernier email de relance envoyé")
@@ -158,6 +246,7 @@ class Invoice(models.Model):
             self.number = 'DRAFT-{}'.format(self.pk)
             self.save()
 
+    @property
     def amount(self):
         """
         Calcul le montant de la facture
@@ -167,7 +256,7 @@ class Invoice(models.Model):
         for detail in self.details.all():
             total += detail.total()
         return total.quantize(Decimal('0.01'))
-    amount.short_description = 'Montant'
+    amount.fget.short_description = 'Montant'
 
     def amount_before_tax(self):
         total = Decimal('0.0')
@@ -176,42 +265,16 @@ class Invoice(models.Model):
         return total.quantize(Decimal('0.01'))
     amount_before_tax.short_description = 'Montant HT'
 
-    def amount_paid(self):
-        """
-        Calcul le montant déjà payé à partir des allocations de paiements
-        """
-        return sum([a.amount for a in self.allocations.all()])
-    amount_paid.short_description = 'Montant payé'
-
-    def amount_remaining_to_pay(self):
-        """
-        Calcul le montant restant à payer
-        """
-        return self.amount() - self.amount_paid()
-    amount_remaining_to_pay.short_description = 'Reste à payer'
-
-    def has_owner(self, username):
-        """
-        Check if passed username (ex gmajax) is owner of the invoice
-        """
-        return (self.member and self.member.username == username)
-
-    def generate_pdf(self):
-        """
-        Make and store a pdf file for the invoice
-        """
-        context = {"invoice": self}
-        context.update(branding(None))
-        pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
-        self.pdf.save('%s.pdf' % self.number, pdf_file)
 
     @transaction.atomic
-    def validate(self):
+    def validate(self, custom_date=None):
         """
         Switch invoice to validate mode. This set to False the draft field
         and generate the pdf
         """
-        self.date = datetime.date.today()
+
+        self.date = custom_date or datetime.date.today()
+
         if not self.date_due:
             self.date_due = self.date + datetime.timedelta(days=settings.PAYMENT_DELAY)
         old_number = self.number
@@ -223,7 +286,7 @@ class Invoice(models.Model):
 
         accounting_log.info("Draft invoice %s validated as invoice %s. "
                             "(Total amount : %f ; Member : %s)"
-                            % (old_number, self.number, self.amount(), self.member))
+                            % (old_number, self.number, self.amount, self.member))
         assert self.pdf_exists()
         if self.member is not None:
             update_accounting_for_member(self.member)
@@ -234,11 +297,8 @@ class Invoice(models.Model):
                 and bool(self.pdf)
                 and private_files_storage.exists(self.pdf.name))
 
-    def get_absolute_url(self):
-        return reverse('billing:invoice', args=[self.number])
-
     def __unicode__(self):
-        return '#%s %0.2f€ %s' % (self.number, self.amount(), self.date_due)
+        return '#%s %0.2f€ %s' % (self.number, self.amount, self.date_due)
 
     def reminder_needed(self):
 
@@ -299,6 +359,18 @@ class Invoice(models.Model):
         self.save()
         return True
 
+    def log_change(self, created):
+
+        if created:
+            accounting_log.info("Creating draft invoice %s (Member: %s)."
+                                % ('DRAFT-{}'.format(self.pk), self.member))
+        else:
+            if not self.validated:
+                accounting_log.info("Updating draft invoice %s (Member: %s)."
+                        % (self.number, self.member))
+            else:
+                accounting_log.info("Updating invoice %s (Member: %s, Total amount: %s, Amount paid: %s)."
+                        % (self.number, self.member, self.amount, self.amount_paid() ))
     class Meta:
         verbose_name = 'facture'
 
@@ -308,7 +380,7 @@ class Invoice(models.Model):
 class InvoiceDetail(models.Model):
 
     label = models.CharField(max_length=100)
-    amount = models.DecimalField(max_digits=5, decimal_places=2,
+    amount = models.DecimalField(max_digits=8, decimal_places=2,
                                  verbose_name='montant')
     quantity = models.DecimalField(null=True, verbose_name='quantité',
                                    default=1.0, decimal_places=2, max_digits=4)
@@ -346,12 +418,75 @@ class InvoiceDetail(models.Model):
         verbose_name = 'détail de facture'
 
 
+class Donation(Bill):
+    _amount = models.DecimalField(max_digits=8, decimal_places=2,
+                                  verbose_name='Montant')
+
+    @property
+    def amount(self):
+        return self._amount
+    amount.fget.short_description = 'Montant'
+
+    @property
+    def validated(self):
+        return True
+
+    def save(self, *args, **kwargs):
+
+        # Only if no amount already allocated...
+        if self.pk is None and (not self.member or self.member.balance < self.amount):
+            raise ValidationError("Le solde n'est pas suffisant pour payer ce don. \
+                        Merci de commencer par enregistrer un paiement pour ce membre.")
+        super(Donation, self).save(*args, **kwargs)
+
+    class Meta:
+        verbose_name = 'don'
+
+class MembershipFee(Bill):
+    _amount = models.DecimalField(null=False, max_digits=8, decimal_places=2,
+                                 default=settings.MEMBER_DEFAULT_COTISATION,
+                                 verbose_name='montant', help_text='en €')
+    start_date = models.DateField(
+        null=False,
+        blank=False,
+        verbose_name='date de début de cotisation')
+    end_date = models.DateField(
+        null=False,
+        blank=True,
+        verbose_name='date de fin de cotisation',
+        help_text='par défaut, la cotisation dure un an')
+
+    @property
+    def amount(self):
+        return self._amount
+    amount.fget.short_description = 'Montant'
+    @property
+    def validated(self):
+        return True
+
+    def save(self, *args, **kwargs):
+        # Only if no amount already allocated...
+        if self.pk is None and (not self.member or self.member.balance < self.amount):
+            raise ValidationError("Le solde n'est pas suffisant pour payer cette cotisation. \
+                        Merci de commencer par enregistrer un paiement pour ce membre.")
+
+        super(MembershipFee, self).save(*args, **kwargs)
+
+
+    def clean(self):
+        if self.start_date is not None and self.end_date is None:
+            self.end_date = self.start_date + datetime.timedelta(364)
+
+    class Meta:
+        verbose_name = 'cotisation'
+
 class Payment(models.Model):
 
     PAYMENT_MEAN_CHOICES = (
         ('cash', 'Espèces'),
         ('check', 'Chèque'),
         ('transfer', 'Virement'),
+        ('creditnote', 'Avoir'),
         ('other', 'Autre')
     )
 
@@ -364,10 +499,10 @@ class Payment(models.Model):
                                     default='transfer',
                                     choices=PAYMENT_MEAN_CHOICES,
                                     verbose_name='moyen de paiement')
-    amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
+    amount = models.DecimalField(max_digits=8, decimal_places=2, null=True,
                                  verbose_name='montant')
     date = models.DateField(default=datetime.date.today)
-    invoice = models.ForeignKey(Invoice, verbose_name='facture associée', null=True,
+    bill = models.ForeignKey(Bill, verbose_name='facture associée', null=True,
                                 blank=True, related_name='payments')
 
     label = models.CharField(max_length=500,
@@ -380,9 +515,9 @@ class Payment(models.Model):
         if self.amount_already_allocated() == 0:
 
             # If there's a linked invoice and no member defined
-            if self.invoice and not self.member:
+            if self.bill and not self.member:
                 # Automatically set member to invoice's member
-                self.member = self.invoice.member
+                self.member = self.bill.member
 
         super(Payment, self).save(*args, **kwargs)
 
@@ -394,7 +529,7 @@ class Payment(models.Model):
 
             # 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():
+            if self.bill and self.amount > self.bill.amount_remaining_to_pay():
                 raise ValidationError("This payment would pay more than the invoice's remaining to pay")
 
     def amount_already_allocated(self):
@@ -404,30 +539,30 @@ class Payment(models.Model):
         return self.amount - self.amount_already_allocated()
 
     @transaction.atomic
-    def allocate_to_invoice(self, invoice):
+    def allocate_to_bill(self, bill):
 
         # 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_pay  = bill.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"
+        accounting_log.info("Allocating %f from payment %s to bill %s %s"
                             % (float(amount_to_allocate), str(self.date),
-                               invoice.number))
+                               bill.reference, bill.pk))
 
-        PaymentAllocation.objects.create(invoice=invoice,
+        PaymentAllocation.objects.create(bill=bill,
                                          payment=self,
                                          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"
+        if (bill.amount_remaining_to_pay() <= 0) and (bill.status == "open"):
+            accounting_log.info("Bill %s %s has been paid and is now closed"
+                                % (bill.reference, bill.pk))
+            bill.status = "closed"
 
-        invoice.save()
+        bill.save()
         self.save()
 
     def __unicode__(self):
@@ -448,31 +583,31 @@ class Payment(models.Model):
 # 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',
+    bill = models.ForeignKey(Bill, 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,
+    amount = models.DecimalField(max_digits=8, decimal_places=2, null=True,
                                  verbose_name='montant')
 
 
-def get_active_payment_and_invoices(member):
+def get_active_payment_and_bills(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_bills = Bill.get_member_validated_bills(member)
     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]
+    active_bills = [p for p in this_member_bills if p.amount_remaining_to_pay() > 0]
 
-    return active_payments, active_invoices
+    return active_payments, active_bills
 
 
 def update_accounting_for_member(member):
@@ -482,70 +617,70 @@ def update_accounting_for_member(member):
     """
 
     accounting_log.info("Updating accounting for member %s ..."
-                        % str(member))
+                        % member)
     accounting_log.info("Member %s current balance is %f ..."
-                        % (str(member), float(member.balance)))
+                        % (member, float(member.balance)))
 
-    reconcile_invoices_and_payments(member)
+    reconcile_bills_and_payments(member)
 
-    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_bills = Bill.get_member_validated_bills(member)
     this_member_payments = [p for p in member.payments.order_by("date")]
 
-    member.balance = compute_balance(this_member_invoices,
+    member.balance = compute_balance(this_member_bills,
                                      this_member_payments)
     member.save()
 
     accounting_log.info("Member %s new balance is %f"
-                        % (str(member),  float(member.balance)))
+                        % (member,  float(member.balance)))
 
 
-def reconcile_invoices_and_payments(member):
+def reconcile_bills_and_payments(member):
     """
     Rapproche des factures et des paiements qui sont actifs (paiement non alloué
     ou factures non entièrement payées) automatiquement.
     """
 
-    active_payments, active_invoices = get_active_payment_and_invoices(member)
+    active_payments, active_bills = get_active_payment_and_bills(member)
 
     if active_payments == []:
-        accounting_log.info("(No active payment for %s. No invoice/payment "
+        accounting_log.info("(No active payment for %s. No bill/payment "
                             "reconciliation needed.)."
-                            % str(member))
+                            % member)
         return
-    elif active_invoices == []:
-        accounting_log.info("(No active invoice for %s. No invoice/payment "
+    elif active_bills == []:
+        accounting_log.info("(No active bill for %s. No bill/payment "
                             "reconciliation needed.)."
-                            % str(member))
+                            % member)
         return
 
     accounting_log.info("Initiating reconciliation between "
-                        "invoice and payments for %s" % str(member))
+                        "invoice and payments for %s" % member)
 
-    while active_payments != [] and active_invoices != []:
+    while active_payments != [] and active_bills != []:
 
-        # Only consider the oldest active payment and the oldest active invoice
+        # Only consider the oldest active payment and the oldest active bill
         p = active_payments[0]
 
-        # If this payment is to be allocated for a specific invoice...
-        if p.invoice:
+        # If this payment is to be allocated for a specific bill...
+        if p.bill:
             # Assert that the invoice is still 'active'
-            assert p.invoice in active_invoices
-            i = p.invoice
+            assert p.bill in active_bills
+            i = p.bill
             accounting_log.info("Payment is to be allocated specifically to " \
-                                "invoice %s" % str(i.number))
+                                "bill %s" % str(i.pk))
         else:
-            i = active_invoices[0]
+            i = active_bills[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)
+        p.allocate_to_bill(i)
 
-        active_payments, active_invoices = get_active_payment_and_invoices(member)
+        active_payments, active_bills = get_active_payment_and_bills(member)
 
     if active_payments == []:
         accounting_log.info("No more active payment. Nothing to reconcile anymore.")
-    elif active_invoices == []:
+    elif active_bills == []:
         accounting_log.info("No more active invoice. Nothing to reconcile anymore.")
     return
 
@@ -583,31 +718,34 @@ def payment_changed(sender, instance, created, **kwargs):
         update_accounting_for_member(instance.member)
 
 
-@receiver(post_save, sender=Invoice)
+@receiver(post_save, sender=Bill)
 @disable_for_loaddata
-def invoice_changed(sender, instance, created, **kwargs):
+def bill_changed(sender, instance, created, **kwargs):
 
-    if created:
-        accounting_log.info("Creating draft invoice %s (Member: %s)."
-                            % ('DRAFT-{}'.format(instance.pk), instance.member))
-    else:
-        if not instance.validated:
-            accounting_log.info("Updating draft invoice %s (Member: %s)."
-                    % (instance.number, instance.member))
-        else:
-            accounting_log.info("Updating invoice %s (Member: %s, Total amount: %s, Amount paid: %s)."
-                    % (instance.number, instance.member, instance.amount(), instance.amount_paid() ))
+    instance.log_change(created)
+
+@receiver(post_save, sender=MembershipFee)
+@disable_for_loaddata
+def fee_changed(sender, instance, created, **kwargs):
+    if created and instance.member is not None:
+        update_accounting_for_member(instance.member)
+
+@receiver(post_save, sender=Donation)
+@disable_for_loaddata
+def fee_changed(sender, instance, created, **kwargs):
+    if created and instance.member is not None:
+        update_accounting_for_member(instance.member)
 
 @receiver(post_delete, sender=PaymentAllocation)
 def paymentallocation_deleted(sender, instance, **kwargs):
 
-    invoice = instance.invoice
+    bill = instance.bill
 
     # Reopen invoice if relevant
-    if (invoice.amount_remaining_to_pay() > 0) and (invoice.status == "closed"):
-        accounting_log.info("Reopening invoice %s ..." % invoice.number)
-        invoice.status = "open"
-        invoice.save()
+    if (bill.amount_remaining_to_pay() > 0) and (bill.status == "closed"):
+        accounting_log.info("Reopening bill %s ..." % bill.number)
+        bill.status = "open"
+        bill.save()
 
 
 @receiver(post_delete, sender=Payment)
@@ -622,10 +760,10 @@ def payment_deleted(sender, instance, **kwargs):
     if member is None:
         return
 
-    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_bills = Bill.get_member_validated_bills(member)
     this_member_payments = [p for p in member.payments.order_by("date")]
 
-    member.balance = compute_balance(this_member_invoices,
+    member.balance = compute_balance(this_member_bills,
                                      this_member_payments)
     member.save()
 

+ 1 - 1
coin/billing/templates/admin/billing/invoice/change_form.html

@@ -4,7 +4,7 @@
     {% if not original.validated %}
         <li><a href="{% url 'admin:invoice_validate' id=object_id %}">Valider la facture</a></li>
     {% elif original.validated %}
-        <li><a href="{% url 'billing:invoice_pdf' id=object_id %}">Télécharger le PDF</a></li>
+        <li><a href="{% url 'billing:bill_pdf' id=object_id %}">Télécharger le PDF</a></li>
     {% endif %}
     {{ block.super }}
 {% endblock %}

+ 1 - 1
coin/billing/templates/billing/invoice.html

@@ -7,7 +7,7 @@
         <p>Émise le {{ invoice.date }}</p>
     </div>
     <div class="large-4 columns">
-        {% if invoice.validated %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}" target="_blank" class="button expand"><i class="fa fa-file-pdf-o"></i> Télécharger en PDF</a>{% endif %}
+        {% if invoice.validated %}<a href="{% url 'billing:bill_pdf' id=invoice.number %}" target="_blank" class="button expand"><i class="fa fa-file-pdf-o"></i> Télécharger en PDF</a>{% endif %}
     </div>
 </div>
 

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

+ 2 - 2
coin/billing/urls.py

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

+ 1 - 1
coin/billing/utils.py

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

+ 105 - 71
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,53 +14,71 @@ from django.core.urlresolvers import reverse
 from django.utils.html import format_html
 
 from coin.members.models import (
-    Member, CryptoKey, LdapUser, MembershipFee, OfferSubscription)
-from coin.members.membershipfee_filter import MembershipFeeFilter
+    Member, CryptoKey, LdapUser, Offer, OfferSubscription, RowLevelPermission)
 from coin.members.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.utils import delete_selected
 import autocomplete_light
 
-
 class CryptoKeyInline(admin.StackedInline):
     model = CryptoKey
     extra = 0
 
 
-class MembershipFeeInline(admin.TabularInline):
-    model = MembershipFee
-    extra = 0
-    fields = ('start_date', 'end_date', 'amount', 'payment_method',
-              'reference', 'payment_date')
-
-
 class OfferSubscriptionInline(admin.TabularInline):
     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):
     list_display = ('id', 'status', 'username', 'first_name', 'last_name',
                     'nickname', 'organization_name', 'email',
-                    'end_date_of_membership')
+                    'end_date_of_membership', 'balance')
     list_display_links = ('id', 'username', 'first_name', 'last_name')
-    list_filter = ('status', MembershipFeeFilter)
+    list_filter = ()
     search_fields = ['username', 'first_name', 'last_name', 'email', 'nickname']
     ordering = ('status', 'username')
     actions = [delete_selected, 'set_as_member', 'set_as_non_member',
@@ -68,60 +87,74 @@ class MemberAdmin(UserAdmin):
     form = AdminMemberChangeForm
     add_form = MemberCreationForm
 
-    fieldsets = (
-        ('Adhérent', {'fields': (
-            ('status', 'resign_date'),
-            'type',
-            ('first_name', 'last_name', 'nickname'),
-            'organization_name',
-            'comments',
-            'balance')}),
-        ('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}
 
     save_on_top = True
 
-    inlines = [CryptoKeyInline, MembershipFeeInline, OfferSubscriptionInline]
+    inlines = [CryptoKeyInline, 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')
@@ -214,12 +247,13 @@ class MemberAdmin(UserAdmin):
     bulk_send_call_for_membership_fee_email.short_description = 'Envoyer le courriel de relance de cotisation'
 
 
-class MembershipFeeAdmin(admin.ModelAdmin):
-    list_display = ('member', 'end_date', 'amount', 'payment_method',
-                    'payment_date')
-    form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
+class RowLevelPermissionAdmin(admin.ModelAdmin):
+    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,
+                            },
+)

+ 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_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 = [
+    ]

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

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

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

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

+ 72 - 48
coin/members/models.py

@@ -9,17 +9,19 @@ 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.core.mail import send_mail
 from django.core.urlresolvers import reverse
+from django.utils.text import slugify
 from ldapdb.models.fields import CharField, IntegerField, ListField
 from registration.signals import user_registered
 
-from coin.offers.models import OfferSubscription
+from coin.offers.models import Offer, OfferSubscription
 from coin.mixins import CoinLdapSyncMixin
 from coin import utils
 
@@ -40,6 +42,17 @@ def send_registration_notification(sender, user, request=None, **kwargs):
               settings.NOTIFICATION_EMAILS,
               fail_silently=False)
 
+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):
 
@@ -95,10 +108,14 @@ 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,
+    balance = models.DecimalField(max_digits=6, 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
@@ -136,7 +153,9 @@ class Member(CoinLdapSyncMixin, AbstractUser):
 
     # Renvoie la date de fin de la dernière cotisation du membre
     def end_date_of_membership(self):
-        aggregate = self.membership_fees.aggregate(end=Max('end_date'))
+        # Avoid import loop
+        from coin.billing.models import MembershipFee
+        aggregate = MembershipFee.objects.filter(member=self).aggregate(end=Max('end_date'))
         return aggregate['end']
     end_date_of_membership.short_description = "Date de fin d'adhésion"
 
@@ -300,6 +319,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
 
@@ -418,50 +440,6 @@ class CryptoKey(CoinLdapSyncMixin, models.Model):
         verbose_name = 'clé'
 
 
-class MembershipFee(models.Model):
-    PAYMENT_METHOD_CHOICES = (
-        ('cash', 'Espèces'),
-        ('check', 'Chèque'),
-        ('transfer', 'Virement'),
-        ('other', 'Autre')
-    )
-
-    member = models.ForeignKey('Member', related_name='membership_fees',
-                               verbose_name='membre')
-    amount = models.DecimalField(null=False, max_digits=5, decimal_places=2,
-                                 default=settings.MEMBER_DEFAULT_COTISATION,
-                                 verbose_name='montant', help_text='en €')
-    start_date = models.DateField(
-        null=False,
-        blank=False,
-        verbose_name='date de début de cotisation')
-    end_date = models.DateField(
-        null=False,
-        blank=True,
-        verbose_name='date de fin de cotisation',
-        help_text='par défaut, la cotisation dure un an')
-
-    payment_method = models.CharField(max_length=100, null=True, blank=True,
-                                      choices=PAYMENT_METHOD_CHOICES,
-                                      verbose_name='moyen de paiement')
-    reference = models.CharField(max_length=125, null=True, blank=True,
-                                 verbose_name='référence du paiement',
-                                 help_text='numéro de chèque, '
-                                 'référence de virement, commentaire...')
-    payment_date = models.DateField(null=True, blank=True,
-                                    verbose_name='date du paiement')
-
-    def clean(self):
-        if self.start_date is not None and self.end_date is None:
-            self.end_date = self.start_date + datetime.timedelta(364)
-
-    def __unicode__(self):
-        return '%s - %s - %i€' % (self.member, self.start_date, self.amount)
-
-    class Meta:
-        verbose_name = 'cotisation'
-
-
 class LdapUser(ldapdb.models.Model):
     # "ou=users,ou=unix,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
     base_dn = settings.LDAP_USER_BASE_DN
@@ -510,6 +488,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):
     """
@@ -529,3 +508,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"

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

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

+ 1 - 158
coin/members/tests.py

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

+ 4 - 2
coin/members/views.py

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

+ 26 - 1
coin/offers/admin.py

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

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

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

+ 18 - 0
coin/offers/models.py

@@ -7,10 +7,26 @@ 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
 
 from coin.resources.models import IPPool
 
 
+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.
 
@@ -48,6 +64,8 @@ class Offer(models.Model):
 
     ip_pools = models.ManyToManyField(IPPool, through='OfferIPPool')
 
+    objects = OfferManager()
+
     def get_configuration_type_display(self):
         """
         Renvoi le nom affichable du type de configuration

+ 4 - 1
coin/settings_base.py

@@ -137,7 +137,7 @@ TEMPLATE_DIRS = (
     os.path.join(PROJECT_PATH, 'templates/'),
 )
 
-EXTRA_TEMPLATE_DIRS = tuple()
+EXTRA_TEMPLATE_DIRS = ('./arn/templates',)
 
 INSTALLED_APPS = (
     'django.contrib.auth',
@@ -296,3 +296,6 @@ MEMBER_CAN_EDIT_VPS_CONF = True
 
 # Allow user to edit their VPN Info
 MEMBER_CAN_EDIT_VPN_CONF = True
+
+# Add subscription comments in invoice items
+ADD_COMMENTS_IN_BILLING = True

+ 154 - 0
doc/user/permissions.md

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

+ 50 - 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()
@@ -40,6 +43,7 @@ class AvailabilityFilter(admin.SimpleListFilter):
         return [
             ('available', 'Disponible'),
             ('borrowed', 'Emprunté'),
+            ('deployed', 'Déployé'),
         ]
 
     def queryset(self, request, queryset):
@@ -47,6 +51,8 @@ class AvailabilityFilter(admin.SimpleListFilter):
             return queryset.available()
         elif self.value() == 'borrowed':
             return queryset.borrowed()
+        elif self.value() == 'deployed':
+            return queryset.deployed()
         else:
             return queryset
 
@@ -55,7 +61,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 +69,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 +128,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 +149,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 +169,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)

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

+ 36 - 6
hardware_provisioning/models.py

@@ -2,6 +2,7 @@
 
 from __future__ import unicode_literals
 from django.db import models
+from django.db.models import Q
 from django.conf import settings
 from django.utils import timezone
 
@@ -24,11 +25,22 @@ class ItemQuerySet(models.QuerySet):
         return Loan.objects.running().values_list('item', flat=True)
 
     def available(self):
-        return self.exclude(pk__in=self._get_borrowed_pks())
+        return self.exclude(
+            pk__in=self._get_borrowed_pks()).exclude(deployed=True)
 
     def borrowed(self):
         return self.filter(pk__in=self._get_borrowed_pks())
 
+    def deployed(self):
+        return self.filter(deployed=True)
+
+    def unavailable(self):
+        """ deployed or borrowed
+        """
+        return self.filter(
+            Q(pk__in=self._get_borrowed_pks()) |
+            Q(deployed=True))
+
 
 class Item(models.Model):
     type = models.ForeignKey(ItemType, verbose_name='type de matériel',
@@ -54,6 +66,8 @@ class Item(models.Model):
         related_name='items',
         null=True, blank=True,
         help_text="dans le cas de matériel n'appartenant pas à l'association")
+    deployed = models.BooleanField(verbose_name='déployé', default=False,
+                                   help_text='Cocher si le matériel est en production')
     comment = models.TextField(verbose_name='commentaire', blank=True,
                                null=True)
 
@@ -81,15 +95,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 +148,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'

+ 46 - 11
hardware_provisioning/tests.py

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

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

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

+ 6 - 4
requirements.txt

@@ -1,19 +1,21 @@
 Django>=1.8.17,<1.9
-psycopg2==2.5.2
+psycopg2==2.5.4
 python-ldap==2.4.15
 wsgiref==0.1.2
 python-dateutil==2.2
-django-autocomplete-light==2.1.1
+django-autocomplete-light>=2.2.10,<2.3
 django-activelink==0.4
 html2text
 django-polymorphic==0.7.2
 django-sendfile==0.3.10
 django-localflavor==1.1
 django-netfields>=0.4,<0.5
-django-ldapdb>=0.4.0,<5.0
+django-ldapdb>=0.4.0,<0.5.0
 django-multiselectfield>=0.1.5
 feedparser
 six==1.10.0
 WeasyPrint==0.31
 freezegun==0.3.8
-django-registration==2.2
+django-registration>=2.4,<2.5
+pyldap<3.0
+unidecode

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

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

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

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