#156 Reformates all the code with Black to unify it make it more PEP8-friendly

Fermé
rezemika veut fusionner 1 commits à partir de rezemika/black vers FFDN/master
73 fichiers modifiés avec 2785 ajouts et 1909 suppressions
  1. 9 6
      coin/apps.py
  2. 66 44
      coin/billing/admin.py
  3. 43 31
      coin/billing/create_subscriptions_invoices.py
  4. 12 10
      coin/billing/management/commands/charge_subscriptions.py
  5. 77 62
      coin/billing/management/commands/import_payments_from_csv.py
  6. 0 1
      coin/billing/management/commands/send_reminders_for_unpaid_bills.py
  7. 289 185
      coin/billing/models.py
  8. 164 131
      coin/billing/tests.py
  9. 4 5
      coin/billing/urls.py
  10. 2 3
      coin/billing/utils.py
  11. 17 8
      coin/billing/views.py
  12. 14 4
      coin/configuration/admin.py
  13. 11 7
      coin/configuration/forms.py
  14. 20 16
      coin/configuration/models.py
  15. 1 1
      coin/context_processors.py
  16. 9 9
      coin/filtering_queryset.py
  17. 6 8
      coin/html2pdf.py
  18. 76 37
      coin/isp_database/admin.py
  19. 2 1
      coin/isp_database/context_processors.py
  20. 153 86
      coin/isp_database/models.py
  21. 6 4
      coin/isp_database/templatetags/isptags.py
  22. 15 12
      coin/isp_database/tests.py
  23. 160 92
      coin/members/admin.py
  24. 17 12
      coin/members/autocomplete_light_registry.py
  25. 50 19
      coin/members/forms.py
  26. 34 23
      coin/members/management/commands/call_for_membership_fees.py
  27. 23 17
      coin/members/management/commands/members_email.py
  28. 10 11
      coin/members/membershipfee_filter.py
  29. 241 170
      coin/members/models.py
  30. 154 129
      coin/members/tests.py
  31. 63 42
      coin/members/urls.py
  32. 34 27
      coin/members/views.py
  33. 10 9
      coin/mixins.py
  34. 55 28
      coin/offers/admin.py
  35. 5 2
      coin/offers/forms.py
  36. 28 11
      coin/offers/management/commands/offer_subscriptions_count.py
  37. 98 58
      coin/offers/models.py
  38. 28 22
      coin/offers/offersubscription_filter.py
  39. 7 3
      coin/offers/urls.py
  40. 35 16
      coin/offers/views.py
  41. 7 6
      coin/resources/admin.py
  42. 83 35
      coin/resources/models.py
  43. 1 0
      coin/resources/templatetags/subnets.py
  44. 2 2
      coin/reverse_dns/admin.py
  45. 38 23
      coin/reverse_dns/models.py
  46. 87 98
      coin/settings_base.py
  47. 21 21
      coin/settings_local.example-illyse.py
  48. 1 4
      coin/settings_test.py
  49. 13 18
      coin/urls.py
  50. 27 17
      coin/utils.py
  51. 4 4
      coin/validation.py
  52. 11 9
      coin/views.py
  53. 1 0
      coin/wsgi.py
  54. 58 20
      contrib/vpn_acct.py
  55. 1 1
      hardware_provisioning/__init__.py
  56. 78 56
      hardware_provisioning/admin.py
  57. 3 3
      hardware_provisioning/app.py
  58. 4 6
      hardware_provisioning/fields.py
  59. 14 14
      hardware_provisioning/forms.py
  60. 71 59
      hardware_provisioning/models.py
  61. 14 16
      hardware_provisioning/tests.py
  62. 6 6
      hardware_provisioning/urls.py
  63. 1 2
      hardware_provisioning/validators.py
  64. 42 45
      hardware_provisioning/views.py
  65. 1 0
      simple_dsl/admin.py
  66. 10 7
      simple_dsl/models.py
  67. 1 1
      vpn/__init__.py
  68. 34 13
      vpn/admin.py
  69. 2 2
      vpn/apps.py
  70. 53 32
      vpn/models.py
  71. 8 7
      vpn/tests.py
  72. 11 5
      vpn/urls.py
  73. 29 15
      vpn/views.py

+ 9 - 6
coin/apps.py

@@ -8,19 +8,21 @@ from .utils import rstrip_str
 
 class AppURLsMeta(type):
     def __init__(cls, name, bases, data):
-        if len(bases) > 1: # execute only on leaf class
-            exported_urlpatterns = data.pop('exported_urlpatterns', None)
+        if len(bases) > 1:  # execute only on leaf class
+            exported_urlpatterns = data.pop("exported_urlpatterns", None)
 
             if exported_urlpatterns:
                 cls.exported_urlpatterns = exported_urlpatterns
             else:
                 # Default : sets
                 #   exported_urlpatterns = [(<app_name>, <app_url_module>)]
-                current_path = '.' + rstrip_str(rstrip_str(basename(__file__), '.pyc'), '.py')
-                url_module = rstrip_str(cls.__module__, current_path) + '.urls'
-                cls.exported_urlpatterns = [(data['name'], url_module)]
+                current_path = "." + rstrip_str(
+                    rstrip_str(basename(__file__), ".pyc"), ".py"
+                )
+                url_module = rstrip_str(cls.__module__, current_path) + ".urls"
+                cls.exported_urlpatterns = [(data["name"], url_module)]
 
-            cls.urlprefix = data.pop('urlprefix', None)
+            cls.urlprefix = data.pop("urlprefix", None)
 
 
 class AppURLs(six.with_metaclass(AppURLsMeta)):
@@ -41,4 +43,5 @@ class AppURLs(six.with_metaclass(AppURLsMeta)):
             name = 'my_app'
             exported_urlpatterns = [('my_app', 'myapp.cool_urls')]
     """
+
     pass

+ 66 - 44
coin/billing/admin.py

@@ -17,8 +17,10 @@ import autocomplete_light
 class InvoiceDetailInline(LimitedAdminInlineMixin, admin.StackedInline):
     model = InvoiceDetail
     extra = 0
-    fields = (('label', 'amount', 'quantity', 'tax'),
-              ('offersubscription', 'period_from', 'period_to'))
+    fields = (
+        ("label", "amount", "quantity", "tax"),
+        ("offersubscription", "period_from", "period_to"),
+    )
 
     def get_filters(self, obj):
         """
@@ -27,13 +29,13 @@ class InvoiceDetailInline(LimitedAdminInlineMixin, admin.StackedInline):
         une liste vide
         """
         if obj and obj.member:
-            return (('offersubscription', {'member': obj.member}),)
+            return (("offersubscription", {"member": obj.member}),)
         else:
-            return (('offersubscription', None),)
+            return (("offersubscription", None),)
 
     def get_readonly_fields(self, request, obj=None):
         if not obj or not obj.member:
-            return self.readonly_fields + ('offersubscription',)
+            return self.readonly_fields + ("offersubscription",)
         return self.readonly_fields
 
 
@@ -44,6 +46,7 @@ class InvoiceDetailInlineReadOnly(admin.StackedInline):
     Ce inline est donc identique à InvoiceDetailInline, mais tous
     les champs sont en lecture seule
     """
+
     model = InvoiceDetail
     extra = 0
     fields = InvoiceDetailInline.fields
@@ -56,11 +59,13 @@ class InvoiceDetailInlineReadOnly(admin.StackedInline):
         if self.declared_fieldsets:
             result = flatten_fieldsets(self.declared_fieldsets)
         else:
-            result = list(set(
-                [field.name for field in self.opts.local_fields] +
-                [field.name for field in self.opts.local_many_to_many]
-            ))
-            result.remove('id')
+            result = list(
+                set(
+                    [field.name for field in self.opts.local_fields]
+                    + [field.name for field in self.opts.local_many_to_many]
+                )
+            )
+            result.remove("id")
         return result
 
 
@@ -82,7 +87,7 @@ class PaymentAllocatedReadOnly(admin.TabularInline):
 class PaymentInlineAdd(admin.StackedInline):
     model = Payment
     extra = 0
-    fields = (('date', 'payment_mean', 'amount'),)
+    fields = (("date", "payment_mean", "amount"),)
     can_delete = False
 
     verbose_name_plural = "Ajouter des paiements"
@@ -92,16 +97,17 @@ class PaymentInlineAdd(admin.StackedInline):
 
 
 class InvoiceAdmin(admin.ModelAdmin):
-    list_display = ('number', 'date', 'status', 'amount', 'member',
-                    'validated')
-    list_display_links = ('number', 'date')
-    fields = (('number', 'date', 'status'),
-              ('date_due'),
-              ('member'),
-              ('amount', 'amount_paid'),
-              ('validated', 'pdf'))
-    readonly_fields = ('amount', 'amount_paid', 'validated', 'pdf', 'number')
-    form = autocomplete_light.modelform_factory(Invoice, fields='__all__')
+    list_display = ("number", "date", "status", "amount", "member", "validated")
+    list_display_links = ("number", "date")
+    fields = (
+        ("number", "date", "status"),
+        ("date_due"),
+        ("member"),
+        ("amount", "amount_paid"),
+        ("validated", "pdf"),
+    )
+    readonly_fields = ("amount", "amount_paid", "validated", "pdf", "number")
+    form = autocomplete_light.modelform_factory(Invoice, fields="__all__")
 
     def get_readonly_fields(self, request, obj=None):
         """
@@ -111,10 +117,12 @@ class InvoiceAdmin(admin.ModelAdmin):
             if self.declared_fieldsets:
                 return flatten_fieldsets(self.declared_fieldsets)
             else:
-                return list(set(
-                    [field.name for field in self.opts.local_fields] +
-                    [field.name for field in self.opts.local_many_to_many]
-                ))
+                return list(
+                    set(
+                        [field.name for field in self.opts.local_fields]
+                        + [field.name for field in self.opts.local_many_to_many]
+                    )
+                )
         return self.readonly_fields
 
     def get_inline_instances(self, request, obj=None):
@@ -142,9 +150,11 @@ class InvoiceAdmin(admin.ModelAdmin):
             inline = inline_class(self.model, self.admin_site)
 
             if request:
-                if not (inline.has_add_permission(request) or
-                        inline.has_change_permission(request) or
-                        inline.has_delete_permission(request)):
+                if not (
+                    inline.has_add_permission(request)
+                    or inline.has_change_permission(request)
+                    or inline.has_delete_permission(request)
+                ):
                     continue
                 if not inline.has_add_permission(request):
                     inline.max_num = 0
@@ -158,9 +168,11 @@ class InvoiceAdmin(admin.ModelAdmin):
         """
         urls = super(InvoiceAdmin, self).get_urls()
         my_urls = [
-            url(r'^validate/(?P<id>.+)$',
+            url(
+                r"^validate/(?P<id>.+)$",
                 self.admin_site.admin_view(self.validate_view),
-                name='invoice_validate'),
+                name="invoice_validate",
+            )
         ]
         return my_urls + urls
 
@@ -174,18 +186,18 @@ class InvoiceAdmin(admin.ModelAdmin):
         if request.user.is_superuser:
             invoice = get_invoice_from_id_or_number(id)
             if invoice.amount() == 0:
-                messages.error(request, 'Une facture validée ne peut pas avoir'
-                                        ' un total de 0€.')
+                messages.error(
+                    request, "Une facture validée ne peut pas avoir" " un total de 0€."
+                )
             else:
                 invoice.validate()
-                messages.success(request, 'La facture a été validée.')
+                messages.success(request, "La facture a été validée.")
         else:
             messages.error(
-                request, 'Vous n\'avez pas l\'autorisation de valider '
-                         'une facture.')
+                request, "Vous n'avez pas l'autorisation de valider " "une facture."
+            )
 
-        return HttpResponseRedirect(reverse('admin:billing_invoice_change',
-                                            args=(id,)))
+        return HttpResponseRedirect(reverse("admin:billing_invoice_change", args=(id,)))
 
 
 class PaymentAllocationInlineReadOnly(admin.TabularInline):
@@ -205,14 +217,23 @@ class PaymentAllocationInlineReadOnly(admin.TabularInline):
 
 class PaymentAdmin(admin.ModelAdmin):
 
-    list_display = ('__unicode__', 'member', 'payment_mean', 'amount', 'date',
-                    'amount_already_allocated', 'label')
+    list_display = (
+        "__unicode__",
+        "member",
+        "payment_mean",
+        "amount",
+        "date",
+        "amount_already_allocated",
+        "label",
+    )
     list_display_links = ()
-    fields = (('member'),
-              ('amount', 'payment_mean', 'date', 'label'),
-              ('amount_already_allocated'))
-    readonly_fields = ('amount_already_allocated', 'label')
-    form = autocomplete_light.modelform_factory(Payment, fields='__all__')
+    fields = (
+        ("member"),
+        ("amount", "payment_mean", "date", "label"),
+        ("amount_already_allocated"),
+    )
+    readonly_fields = ("amount_already_allocated", "label")
+    form = autocomplete_light.modelform_factory(Payment, fields="__all__")
 
     def get_readonly_fields(self, request, obj=None):
 
@@ -226,5 +247,6 @@ class PaymentAdmin(admin.ModelAdmin):
     def get_inline_instances(self, request, obj=None):
         return [PaymentAllocationInlineReadOnly(self.model, self.admin_site)]
 
+
 admin.site.register(Invoice, InvoiceAdmin)
 admin.site.register(Payment, PaymentAdmin)

+ 43 - 31
coin/billing/create_subscriptions_invoices.py

@@ -21,8 +21,9 @@ def create_all_members_invoices_for_a_period(date=None):
     if date is None:
         date = datetime.date.today()
     members = Member.objects.filter(
-        Q(offersubscription__resign_date__isnull=True) |
-        Q(offersubscription__resign_date__gte=date))
+        Q(offersubscription__resign_date__isnull=True)
+        | Q(offersubscription__resign_date__gte=date)
+    )
 
     invoices = []
 
@@ -33,6 +34,7 @@ def create_all_members_invoices_for_a_period(date=None):
 
     return invoices
 
+
 @transaction.atomic
 def create_member_invoice_for_a_period(member, date):
     """
@@ -43,14 +45,12 @@ def create_member_invoice_for_a_period(member, date):
     sid = transaction.savepoint()
 
     date_first_of_month = datetime.date(date.year, date.month, 1)
-    date_last_of_month = (date_first_of_month + relativedelta(months=+1) -
-                          relativedelta(days=+1))
-
-    invoice = Invoice.objects.create(
-        date_due=datetime.date.today(),
-        member=member
+    date_last_of_month = (
+        date_first_of_month + relativedelta(months=+1) - relativedelta(days=+1)
     )
 
+    invoice = Invoice.objects.create(date_due=datetime.date.today(), member=member)
+
     # Récupère les abonnements actifs du membre à la fin du mois
     offer_subscriptions = member.get_active_subscriptions(date_last_of_month)
 
@@ -67,22 +67,26 @@ def create_member_invoice_for_a_period(member, date):
         # Alors facture en plus les frais de mise en service
         invoicedetail_test_first = InvoiceDetail.objects.filter(
             offersubscription__exact=offer_subscription.pk,
-            invoice__member__exact=member.pk)
+            invoice__member__exact=member.pk,
+        )
         if not invoicedetail_test_first.exists():
             invoice.details.create(
                 label=offer.name + " - Frais de mise en service",
                 amount=offer.initial_fees,
                 offersubscription=offer_subscription,
                 period_from=None,
-                period_to=None)
+                period_to=None,
+            )
 
         # Période de facturation de l'item par defaut
         # - Du début du mois de la date passée en paramètre
         # - Jusqu'à la fin du mois de la période de facturation de l'offre
         period_from = date_first_of_month
-        period_to = (date_first_of_month +
-                     relativedelta(months=+offer.billing_period) -
-                     relativedelta(days=+1))
+        period_to = (
+            date_first_of_month
+            + relativedelta(months=+offer.billing_period)
+            - relativedelta(days=+1)
+        )
         planned_period_number_of_days = (period_to - period_from).days + 1
         quantity = 1
 
@@ -90,8 +94,10 @@ def create_member_invoice_for_a_period(member, date):
         # date de début de facturation au jour de l'ouverture de
         # l'abonnement
         if date_first_of_month == datetime.date(
-                offer_subscription.subscription_date.year,
-                offer_subscription.subscription_date.month, 1):
+            offer_subscription.subscription_date.year,
+            offer_subscription.subscription_date.month,
+            1,
+        ):
             period_from = offer_subscription.subscription_date
 
         # Recherche dans les factures déjà existantes de ce membre des
@@ -102,7 +108,8 @@ def create_member_invoice_for_a_period(member, date):
             offersubscription__exact=offer_subscription.pk,
             period_from__lte=period_from,
             period_to__gt=period_from,
-            invoice__member__exact=member.pk)
+            invoice__member__exact=member.pk,
+        )
 
         # Si une facture de ce genre existe alors ne fait rien.
         if not invoicedetail_test_before.exists():
@@ -115,23 +122,26 @@ def create_member_invoice_for_a_period(member, date):
                 offersubscription__exact=offer_subscription.pk,
                 period_from__lte=period_to,
                 period_from__gte=period_from,
-                invoice__member__exact=member.pk)
+                invoice__member__exact=member.pk,
+            )
 
             # Si une telle facture existe, récupère la date de début de
             # facturation pour en faire la date de fin de facturation
             if invoicedetail_test_after.exists():
                 invoicedetail_after = invoicedetail_test_after.first()
-                period_to = (
-                    datetime.date(invoicedetail_after.period_from.year,
-                                  invoicedetail_after.period_from.month, 1) -
-                    relativedelta(days=+1))
+                period_to = datetime.date(
+                    invoicedetail_after.period_from.year,
+                    invoicedetail_after.period_from.month,
+                    1,
+                ) - relativedelta(days=+1)
 
             # Si la période de facturation varie par rapport à celle prévue par
             # l'offre, calcul au prorata en faisant varier la quantité
             period_number_of_days = (period_to - period_from).days + 1
             if planned_period_number_of_days != period_number_of_days:
-                quantity = (Decimal(period_number_of_days) /
-                            Decimal(planned_period_number_of_days))
+                quantity = Decimal(period_number_of_days) / Decimal(
+                    planned_period_number_of_days
+                )
 
             # Si durée de 0jours ou dates incohérentes, alors on ajoute pas
             # (Si la period est de 0jours c'est que la facture existe déjà.)
@@ -140,23 +150,25 @@ def create_member_invoice_for_a_period(member, date):
                 # à la facture
                 label = offer.name
                 try:
-                    if (offer_subscription.configuration.comment):
+                    if offer_subscription.configuration.comment:
                         label += " (%s)" % offer_subscription.configuration.comment
                 except ObjectDoesNotExist:
                     pass
 
-                invoice.details.create(label=label,
-                                       amount=offer.period_fees,
-                                       quantity=quantity,
-                                       offersubscription=offer_subscription,
-                                       period_from=period_from,
-                                       period_to=period_to)
+                invoice.details.create(
+                    label=label,
+                    amount=offer.period_fees,
+                    quantity=quantity,
+                    offersubscription=offer_subscription,
+                    period_from=period_from,
+                    period_to=period_to,
+                )
 
     # S'il n'y a pas d'items dans la facture, ne commit pas la transaction.
     if invoice.details.count() > 0:
         invoice.save()
         transaction.savepoint_commit(sid)
-        invoice.validate() # Valide la facture et génère le PDF
+        invoice.validate()  # Valide la facture et génère le PDF
         return invoice
     else:
         transaction.savepoint_rollback(sid)

+ 12 - 10
coin/billing/management/commands/charge_subscriptions.py

@@ -4,30 +4,32 @@ from django.core.management.base import BaseCommand, CommandError
 from django.conf import settings
 
 from coin.utils import respect_language
-from coin.billing.create_subscriptions_invoices import create_all_members_invoices_for_a_period
+from coin.billing.create_subscriptions_invoices import (
+    create_all_members_invoices_for_a_period
+)
 
 
 class Command(BaseCommand):
-    args = '[date=2011-07-04]'
-    help = 'Create invoices for members subscriptions for date specified (or today if no date passed)'
+    args = "[date=2011-07-04]"
+    help = "Create invoices for members subscriptions for date specified (or today if no date passed)"
 
     def handle(self, *args, **options):
-        verbosity = int(options['verbosity'])
+        verbosity = int(options["verbosity"])
         try:
-            date = datetime.datetime.strptime(args[0], '%Y-%m-%d').date()
+            date = datetime.datetime.strptime(args[0], "%Y-%m-%d").date()
         except IndexError:
             date = datetime.date.today()
         except ValueError:
             raise CommandError(
-                'Please enter a valid date : YYYY-mm-dd (ex: 2011-07-04)')
+                "Please enter a valid date : YYYY-mm-dd (ex: 2011-07-04)"
+            )
 
         if verbosity >= 2:
             self.stdout.write(
-                'Create invoices for all members for the date : %s' % date)
+                "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)
 
         if len(invoices) > 0 or verbosity >= 2:
-            self.stdout.write(
-                u'%d invoices were created' % len(invoices))
-
+            self.stdout.write(u"%d invoices were created" % len(invoices))

+ 77 - 62
coin/billing/management/commands/import_payments_from_csv.py

@@ -38,14 +38,15 @@ from coin.billing.models import Payment
 # Parser / import / matcher configuration
 
 # The CSV delimiter
-DELIMITER=str(';')
+DELIMITER = str(";")
 # The date format in the CSV
-DATE_FORMAT="%d/%m/%Y"
+DATE_FORMAT = "%d/%m/%Y"
 # The default regex used to match the label of a payment with a member ID
-ID_REGEX=r"(?i)(\b|_)ID[\s\-\_\/]*(\d+)(\b|_)"
+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 = ["DON", "MECENAT", "REM CHQ"]
+
 
 class Command(BaseCommand):
 
@@ -58,21 +59,16 @@ class Command(BaseCommand):
 
     def add_arguments(self, parser):
 
-        parser.add_argument(
-            'filename',
-            type=str,
-            help="The CSV filename to be parsed"
-        )
+        parser.add_argument("filename", type=str, help="The CSV filename to be parsed")
 
         parser.add_argument(
-            '--commit',
-            action='store_true',
-            dest='commit',
+            "--commit",
+            action="store_true",
+            dest="commit",
             default=False,
-            help='Agree with the proposed change and commit them'
+            help="Agree with the proposed change and commit them",
         )
 
-
     def handle(self, *args, **options):
 
         assert options["filename"] != ""
@@ -80,24 +76,35 @@ class Command(BaseCommand):
         if not os.path.isfile(options["filename"]):
             raise CommandError("This file does not exists.")
 
-        payments = self.convert_csv_to_dicts(self.clean_csv(self.load_csv(options["filename"])))
+        payments = self.convert_csv_to_dicts(
+            self.clean_csv(self.load_csv(options["filename"]))
+        )
 
         payments = self.try_to_match_payment_with_members(payments)
         new_payments = self.filter_already_known_payments(payments)
         new_payments = self.unmatch_payment_with_keywords(new_payments)
 
-        number_of_already_known_payments = len(payments)-len(new_payments)
+        number_of_already_known_payments = len(payments) - len(new_payments)
         number_of_new_payments = len(new_payments)
 
-        if (number_of_new_payments > 0) :
+        if number_of_new_payments > 0:
             print("======================================================")
             print("   > New payments found")
-            print(json.dumps(new_payments, indent=4, separators=(',', ': ')))
+            print(json.dumps(new_payments, indent=4, separators=(",", ": ")))
         print("======================================================")
-        print("Number of already known payments found : " + str(number_of_already_known_payments))
+        print(
+            "Number of already known payments found : "
+            + str(number_of_already_known_payments)
+        )
         print("Number of new payments found           : " + str(number_of_new_payments))
-        print("Number of new payments matched         : " + str(len([p for p in new_payments if     p["member_matched"]])))
-        print("Number of payments not matched         : " + str(len([p for p in new_payments if not p["member_matched"]])))
+        print(
+            "Number of new payments matched         : "
+            + str(len([p for p in new_payments if p["member_matched"]]))
+        )
+        print(
+            "Number of payments not matched         : "
+            + str(len([p for p in new_payments if not p["member_matched"]]))
+        )
         print("======================================================")
 
         if number_of_new_payments == 0:
@@ -105,12 +112,13 @@ class Command(BaseCommand):
             return
 
         if not options["commit"]:
-            print("Please carefully review the matches, then if everything \n" \
-                  "looks alright, use --commit to register these new payments.")
+            print(
+                "Please carefully review the matches, then if everything \n"
+                "looks alright, use --commit to register these new payments."
+            )
         else:
             self.add_new_payments(new_payments)
 
-
     def is_date(self, text):
         try:
             datetime.datetime.strptime(text, DATE_FORMAT)
@@ -118,20 +126,17 @@ class Command(BaseCommand):
         except ValueError:
             return False
 
-
     def is_money_amount(self, text):
         try:
-            float(text.replace(",","."))
+            float(text.replace(",", "."))
             return True
         except ValueError:
             return False
 
-
     def load_csv(self, filename):
         with open(filename, "r") as f:
             return list(csv.reader(f, delimiter=DELIMITER))
 
-
     def clean_csv(self, data):
 
         output = []
@@ -139,13 +144,15 @@ class Command(BaseCommand):
         for i, row in enumerate(data):
 
             for j in range(len(row)):
-                row[j] = row[j].decode('utf-8')
+                row[j] = row[j].decode("utf-8")
 
             if len(row) < 4:
                 continue
 
             if not self.is_date(row[0]):
-                logging.warning("Ignoring the following row (bad format for date in the first column) :")
+                logging.warning(
+                    "Ignoring the following row (bad format for date in the first column) :"
+                )
                 logging.warning(str(row))
                 continue
 
@@ -155,22 +162,25 @@ class Command(BaseCommand):
                 continue
 
             if not self.is_money_amount(row[3]):
-                logging.warning("Ignoring the following row (bad format for money amount in colun three) :")
+                logging.warning(
+                    "Ignoring the following row (bad format for money amount in colun three) :"
+                )
                 logging.warning(str(row))
                 continue
 
             # Clean the date
-            row[0] = datetime.datetime.strptime(row[0], DATE_FORMAT).strftime("%Y-%m-%d")
+            row[0] = datetime.datetime.strptime(row[0], DATE_FORMAT).strftime(
+                "%Y-%m-%d"
+            )
 
             # Clean the label ...
-            row[4] = row[4].replace('\r', ' ')
-            row[4] = row[4].replace('\n', ' ')
+            row[4] = row[4].replace("\r", " ")
+            row[4] = row[4].replace("\n", " ")
 
             output.append(row)
 
         return output
 
-
     def convert_csv_to_dicts(self, data):
 
         output = []
@@ -180,13 +190,12 @@ class Command(BaseCommand):
 
             payment["date"] = row[0]
             payment["label"] = row[4]
-            payment["amount"] = float(row[3].replace(",","."))
+            payment["amount"] = float(row[3].replace(",", "."))
 
             output.append(payment)
 
         return output
 
-
     def try_to_match_payment_with_members(self, payments):
 
         members = Member.objects.filter(status="member")
@@ -201,18 +210,20 @@ class Command(BaseCommand):
             idmatches = idregex.findall(payment_label)
             if len(idmatches) == 1:
                 i = int(idmatches[0][1])
-                member_matches = [ member.username for member in members if member.pk==i ]
+                member_matches = [
+                    member.username for member in members if member.pk == i
+                ]
                 if len(member_matches) == 1:
                     payment["member_matched"] = member_matches[0]
-                    #print("Matched by ID to "+member_matches[0])
+                    # print("Matched by ID to "+member_matches[0])
                     continue
 
-
             # Second, attempt to find the username
             usernamematch = None
             for member in members:
-                matches = re.compile(r"(?i)(\b|_)"+re.escape(member.username)+r"(\b|_)") \
-                            .findall(payment_label)
+                matches = re.compile(
+                    r"(?i)(\b|_)" + re.escape(member.username) + r"(\b|_)"
+                ).findall(payment_label)
                 # If not found, try next
                 if len(matches) == 0:
                     continue
@@ -226,18 +237,18 @@ class Command(BaseCommand):
 
             if usernamematch != None:
                 payment["member_matched"] = usernamematch
-                #print("Matched by username to "+usernamematch)
+                # print("Matched by username to "+usernamematch)
                 continue
 
-
             # Third, attempt to match by family name
             familynamematch = None
             for member in members:
                 if member.last_name == "":
                     continue
 
-                matches = re.compile(r"(?i)(\b|_)"+re.escape(str(member.last_name))+r"(\b|_)") \
-                            .findall(payment_label)
+                matches = re.compile(
+                    r"(?i)(\b|_)" + re.escape(str(member.last_name)) + r"(\b|_)"
+                ).findall(payment_label)
                 # If not found, try next
                 if len(matches) == 0:
                     continue
@@ -256,20 +267,21 @@ class Command(BaseCommand):
 
             if familynamematch != None:
                 payment["member_matched"] = usernamematch
-                #print("Matched by familyname to "+familynamematch)
+                # print("Matched by familyname to "+familynamematch)
                 continue
 
-            #print("Could not match")
+            # print("Could not match")
             payment["member_matched"] = None
 
         return payments
 
-
     def unmatch_payment_with_keywords(self, payments):
 
         matchers = {}
         for keyword in KEYWORDS_TO_NOTMATCH:
-            matchers[keyword] = re.compile(r"(?i)(\b|_|-)"+re.escape(keyword)+r"(\b|_|-)")
+            matchers[keyword] = re.compile(
+                r"(?i)(\b|_|-)" + re.escape(keyword) + r"(\b|_|-)"
+            )
 
         for i, payment in enumerate(payments):
 
@@ -284,9 +296,10 @@ class Command(BaseCommand):
                 if len(matches) == 0:
                     continue
 
-                print("Ignoring possible match for payment '%s' because " \
-                      "it contains the keyword %s"                        \
-                      % (payment["label"], keyword))
+                print(
+                    "Ignoring possible match for payment '%s' because "
+                    "it contains the keyword %s" % (payment["label"], keyword)
+                )
                 payments[i]["member_matched"] = None
 
                 break
@@ -304,9 +317,11 @@ class Command(BaseCommand):
             found_match = False
             for known_payment in known_payments:
 
-                if  (str(known_payment.date) == payment["date"].encode('utf-8')) \
-                and (known_payment.label == payment["label"]) \
-                and (float(known_payment.amount) == float(payment["amount"])):
+                if (
+                    (str(known_payment.date) == payment["date"].encode("utf-8"))
+                    and (known_payment.label == payment["label"])
+                    and (float(known_payment.amount) == float(payment["amount"]))
+                ):
                     found_match = True
                     break
 
@@ -315,7 +330,6 @@ class Command(BaseCommand):
 
         return new_payments
 
-
     def add_new_payments(self, new_payments):
 
         for new_payment in new_payments:
@@ -331,8 +345,9 @@ class Command(BaseCommand):
             print(new_payment)
 
             # Create the payment
-            payment = Payment.objects.create(amount=float(new_payment["amount"]),
-                                             label=new_payment["label"],
-                                             date=new_payment["date"],
-                                             member=member)
-
+            payment = Payment.objects.create(
+                amount=float(new_payment["amount"]),
+                label=new_payment["label"],
+                date=new_payment["date"],
+                member=member,
+            )

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

@@ -34,4 +34,3 @@ weeks.
                 continue
 
             invoice.send_reminder(auto=True)
-

+ 289 - 185
coin/billing/models.py

@@ -20,9 +20,14 @@ from django.core.urlresolvers import reverse
 from coin.offers.models import OfferSubscription
 from coin.members.models import Member
 from coin.html2pdf import render_as_pdf
-from coin.utils import private_files_storage, start_of_month, end_of_month, \
-                       postgresql_regexp, send_templated_email,             \
-                       disable_for_loaddata
+from coin.utils import (
+    private_files_storage,
+    start_of_month,
+    end_of_month,
+    postgresql_regexp,
+    send_templated_email,
+    disable_for_loaddata,
+)
 from coin.isp_database.context_processors import branding
 from coin.isp_database.models import ISPInfo
 
@@ -32,9 +37,7 @@ accounting_log = logging.getLogger("coin.billing")
 def invoice_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())
+    return "invoices/%d_%s_%s.pdf" % (member_id, instance.number, uuid.uuid4())
 
 
 @python_2_unicode_compatible
@@ -50,8 +53,8 @@ class InvoiceNumber:
     - MM month of the bill
     - XXXXXX a per-month sequence
     """
-    RE_INVOICE_NUMBER = re.compile(
-        r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<index>\d{6})')
+
+    RE_INVOICE_NUMBER = re.compile(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<index>\d{6})")
 
     def __init__(self, date, index):
         self.date = date
@@ -61,7 +64,7 @@ class InvoiceNumber:
         return InvoiceNumber(self.date, self.index + 1)
 
     def __str__(self):
-        return '{:%Y-%m}-{:0>6}'.format(self.date, self.index)
+        return "{:%Y-%m}-{:0>6}".format(self.date, self.index)
 
     @classmethod
     def parse(cls, string):
@@ -71,13 +74,13 @@ class InvoiceNumber:
 
         return cls(
             datetime.date(
-                year=int(m.group('year')),
-                month=int(m.group('month')),
-                day=1),
-            int(m.group('index')))
+                year=int(m.group("year")), month=int(m.group("month")), day=1
+            ),
+            int(m.group("index")),
+        )
 
     @staticmethod
-    def time_sequence_filter(date, field_name='date'):
+    def time_sequence_filter(date, field_name="date"):
         """ Build queryset filter to be used to get the invoices from the
         numbering sequence of a given date.
 
@@ -88,8 +91,8 @@ class InvoiceNumber:
         """
 
         return {
-            '{}__month'.format(field_name): date.month,
-            '{}__year'.format(field_name): date.year
+            "{}__month".format(field_name): date.month,
+            "{}__year".format(field_name): date.year,
         }
 
 
@@ -107,58 +110,82 @@ class InvoiceQuerySet(models.QuerySet):
 
     def _get_last_invoice_number(self, date):
         same_seq_filter = InvoiceNumber.time_sequence_filter(date)
-        return self.filter(**same_seq_filter).with_valid_number().aggregate(
-            models.Max('number'))['number__max']
+        return (
+            self.filter(**same_seq_filter)
+            .with_valid_number()
+            .aggregate(models.Max("number"))["number__max"]
+        )
 
     def with_valid_number(self):
         """ Excludes previous numbering schemes or draft invoices
         """
-        return self.filter(number__regex=postgresql_regexp(
-            InvoiceNumber.RE_INVOICE_NUMBER))
+        return self.filter(
+            number__regex=postgresql_regexp(InvoiceNumber.RE_INVOICE_NUMBER)
+        )
 
 
 class Invoice(models.Model):
 
     INVOICES_STATUS_CHOICES = (
-        ('open', 'À payer'),
-        ('closed', 'Réglée'),
-        ('trouble', 'Litige')
+        ("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'
-                                    ' and the invoice cannot be modified')
-    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')
+    validated = models.BooleanField(
+        default=False,
+        verbose_name="validée",
+        help_text="Once validated, a PDF is generated"
+        " and the invoice cannot be modified",
+    )
+    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')
+        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,
+        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é")
+        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é"
+    )
 
     def save(self, *args, **kwargs):
         # First save to get a PK
         super(Invoice, self).save(*args, **kwargs)
         # Then use that pk to build draft invoice number
         if not self.validated and self.pk and not self.number:
-            self.number = 'DRAFT-{}'.format(self.pk)
+            self.number = "DRAFT-{}".format(self.pk)
             self.save()
 
     def amount(self):
@@ -166,38 +193,42 @@ class Invoice(models.Model):
         Calcul le montant de la facture
         en fonction des éléments de détails
         """
-        total = Decimal('0.0')
+        total = Decimal("0.0")
         for detail in self.details.all():
             total += detail.total()
-        return total.quantize(Decimal('0.01'))
-    amount.short_description = 'Montant'
+        return total.quantize(Decimal("0.01"))
+
+    amount.short_description = "Montant"
 
     def amount_before_tax(self):
-        total = Decimal('0.0')
+        total = Decimal("0.0")
         for detail in self.details.all():
             total += detail.amount
-        return total.quantize(Decimal('0.01'))
-    amount_before_tax.short_description = 'Montant HT'
+        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é'
+
+    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'
+
+    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)
+        return self.member and self.member.username == username
 
     def generate_pdf(self):
         """
@@ -205,8 +236,8 @@ class Invoice(models.Model):
         """
         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)
+        pdf_file = render_as_pdf("billing/invoice_pdf.html", context)
+        self.pdf.save("%s.pdf" % self.number, pdf_file)
 
     @transaction.atomic
     def validate(self):
@@ -225,26 +256,25 @@ class Invoice(models.Model):
         self.generate_pdf()
 
         accounting_log.info(
-            "Draft invoice {} validated as invoice {}. ".format(
-                old_number, self.number) +
-            "(Total amount : {} ; Member : {})".format(
-                self.amount(), self.member))
+            "Draft invoice {} validated as invoice {}. ".format(old_number, self.number)
+            + "(Total amount : {} ; Member : {})".format(self.amount(), self.member)
+        )
         assert self.pdf_exists()
         if self.member is not None:
             update_accounting_for_member(self.member)
 
-
     def pdf_exists(self):
-        return (self.validated
-                and bool(self.pdf)
-                and private_files_storage.exists(self.pdf.name))
+        return (
+            self.validated
+            and bool(self.pdf)
+            and private_files_storage.exists(self.pdf.name)
+        )
 
     def get_absolute_url(self):
-        return reverse('billing:invoice', args=[self.number])
+        return reverse("billing:invoice", args=[self.number])
 
     def __unicode__(self):
-        return '#{} {:0.2f}€ {}'.format(
-            self.number, self.amount(), self.date_due)
+        return "#{} {:0.2f}€ {}".format(self.number, self.amount(), self.date_due)
 
     def reminder_needed(self):
 
@@ -253,17 +283,17 @@ class Invoice(models.Model):
             return False
 
         # If bill is close or not validated yet, nope
-        if self.status != 'open' or not self.validated:
+        if self.status != "open" or not self.validated:
             return False
 
         # If bill is not at least one month old, nope
-        if self.date_due >= timezone.now()+relativedelta(weeks=-4):
+        if self.date_due >= timezone.now() + relativedelta(weeks=-4):
             return False
 
         # If a reminder has been recently sent, nope
-        if (self.date_last_reminder_email
-            and (self.date_last_reminder_email
-                 >= timezone.now() + relativedelta(weeks=-3))):
+        if self.date_last_reminder_email and (
+            self.date_last_reminder_email >= timezone.now() + relativedelta(weeks=-3)
+        ):
             return False
 
         return True
@@ -281,25 +311,31 @@ class Invoice(models.Model):
 
         accounting_log.info(
             "Sending reminder email to {} to pay invoice {}".format(
-                self.member, str(self.number)))
+                self.member, str(self.number)
+            )
+        )
 
         isp_info = ISPInfo.objects.first()
         kwargs = {}
         # Il peut ne pas y avir d'ISPInfo, ou bien pas d'administrative_email
         if isp_info and isp_info.administrative_email:
-            kwargs['from_email'] = isp_info.administrative_email
+            kwargs["from_email"] = isp_info.administrative_email
 
         # Si le dernier courriel de relance a été envoyé il y a moins de trois
         # semaines, n'envoi pas un nouveau courriel
         send_templated_email(
             to=self.member.email,
-            subject_template='billing/emails/reminder_for_unpaid_bill.txt',
-            body_template='billing/emails/reminder_for_unpaid_bill.html',
-            context={'member': self.member, 'branding': isp_info,
-                     'membership_info_url': settings.MEMBER_MEMBERSHIP_INFO_URL,
-                     'today': datetime.date.today,
-                     'auto_sent': auto},
-            **kwargs)
+            subject_template="billing/emails/reminder_for_unpaid_bill.txt",
+            body_template="billing/emails/reminder_for_unpaid_bill.html",
+            context={
+                "member": self.member,
+                "branding": isp_info,
+                "membership_info_url": settings.MEMBER_MEMBERSHIP_INFO_URL,
+                "today": datetime.date.today,
+                "auto_sent": auto,
+            },
+            **kwargs
+        )
 
         # Sauvegarde en base la date du dernier envoi de mail de relance
         self.date_last_reminder_email = timezone.now()
@@ -307,7 +343,7 @@ class Invoice(models.Model):
         return True
 
     class Meta:
-        verbose_name = 'facture'
+        verbose_name = "facture"
 
     objects = InvoiceQuerySet().as_manager()
 
@@ -315,71 +351,95 @@ class Invoice(models.Model):
 class InvoiceDetail(models.Model):
 
     label = models.CharField(max_length=100)
-    amount = models.DecimalField(max_digits=5, decimal_places=2,
-                                 verbose_name='montant')
-    quantity = models.DecimalField(null=True, verbose_name='quantité',
-                                   default=1.0, decimal_places=2, max_digits=4)
-    tax = models.DecimalField(null=True, default=0.0, decimal_places=2,
-                              max_digits=4, verbose_name='TVA',
-                              help_text='en %')
-    invoice = models.ForeignKey(Invoice, verbose_name='facture',
-                                related_name='details')
-    offersubscription = models.ForeignKey(OfferSubscription, null=True,
-                                          blank=True, default=None,
-                                          verbose_name='abonnement')
+    amount = models.DecimalField(max_digits=5, decimal_places=2, verbose_name="montant")
+    quantity = models.DecimalField(
+        null=True, verbose_name="quantité", default=1.0, decimal_places=2, max_digits=4
+    )
+    tax = models.DecimalField(
+        null=True,
+        default=0.0,
+        decimal_places=2,
+        max_digits=4,
+        verbose_name="TVA",
+        help_text="en %",
+    )
+    invoice = models.ForeignKey(Invoice, verbose_name="facture", related_name="details")
+    offersubscription = models.ForeignKey(
+        OfferSubscription,
+        null=True,
+        blank=True,
+        default=None,
+        verbose_name="abonnement",
+    )
     period_from = models.DateField(
         default=start_of_month,
         null=True,
         blank=True,
-        verbose_name='début de période',
-        help_text='Date de début de période sur laquelle est facturé cet item')
+        verbose_name="début de période",
+        help_text="Date de début de période sur laquelle est facturé cet item",
+    )
     period_to = models.DateField(
         default=end_of_month,
         null=True,
         blank=True,
-        verbose_name='fin de période',
-        help_text='Date de fin de période sur laquelle est facturé cet item')
+        verbose_name="fin de période",
+        help_text="Date de fin de période sur laquelle est facturé cet item",
+    )
 
     def __unicode__(self):
         return self.label
 
     def total(self):
         """Calcul le total"""
-        return (self.amount * (self.tax / Decimal('100.0') +
-                               Decimal('1.0')) *
-                self.quantity).quantize(Decimal('0.01'))
+        return (
+            self.amount * (self.tax / Decimal("100.0") + Decimal("1.0")) * self.quantity
+        ).quantize(Decimal("0.01"))
 
     class Meta:
-        verbose_name = 'détail de facture'
+        verbose_name = "détail de facture"
 
 
 class Payment(models.Model):
 
     PAYMENT_MEAN_CHOICES = (
-        ('cash', 'Espèces'),
-        ('check', 'Chèque'),
-        ('transfer', 'Virement'),
-        ('other', 'Autre')
+        ("cash", "Espèces"),
+        ("check", "Chèque"),
+        ("transfer", "Virement"),
+        ("other", "Autre"),
     )
 
-    member = models.ForeignKey(Member, null=True, blank=True, default=None,
-                               related_name='payments',
-                               verbose_name='membre',
-                               on_delete=models.SET_NULL)
-
-    payment_mean = models.CharField(max_length=100, null=True,
-                                    default='transfer',
-                                    choices=PAYMENT_MEAN_CHOICES,
-                                    verbose_name='moyen de paiement')
-    amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
-                                 verbose_name='montant')
+    member = models.ForeignKey(
+        Member,
+        null=True,
+        blank=True,
+        default=None,
+        related_name="payments",
+        verbose_name="membre",
+        on_delete=models.SET_NULL,
+    )
+
+    payment_mean = models.CharField(
+        max_length=100,
+        null=True,
+        default="transfer",
+        choices=PAYMENT_MEAN_CHOICES,
+        verbose_name="moyen de paiement",
+    )
+    amount = models.DecimalField(
+        max_digits=5, decimal_places=2, null=True, verbose_name="montant"
+    )
     date = models.DateField(default=datetime.date.today)
-    invoice = models.ForeignKey(Invoice, verbose_name='facture associée', null=True,
-                                blank=True, related_name='payments')
+    invoice = models.ForeignKey(
+        Invoice,
+        verbose_name="facture associée",
+        null=True,
+        blank=True,
+        related_name="payments",
+    )
 
-    label = models.CharField(max_length=500,
-                             null=True, blank=True, default="",
-                             verbose_name='libellé')
+    label = models.CharField(
+        max_length=500, null=True, blank=True, default="", verbose_name="libellé"
+    )
 
     def save(self, *args, **kwargs):
 
@@ -393,7 +453,6 @@ class Payment(models.Model):
 
         super(Payment, self).save(*args, **kwargs)
 
-
     def clean(self):
 
         # Only if no amount already alloca ted...
@@ -402,10 +461,12 @@ 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():
-                raise ValidationError("This payment would pay more than the invoice's remaining to pay")
+                raise ValidationError(
+                    "This payment would pay more than the invoice's remaining to pay"
+                )
 
     def amount_already_allocated(self):
-        return sum([ a.amount for a in self.allocations.all() ])
+        return sum([a.amount for a in self.allocations.all()])
 
     def amount_not_allocated(self):
         return self.amount - self.amount_already_allocated()
@@ -417,22 +478,24 @@ class Payment(models.Model):
         # ...
 
         amount_can_pay = self.amount_not_allocated()
-        amount_to_pay  = invoice.amount_remaining_to_pay()
+        amount_to_pay = invoice.amount_remaining_to_pay()
         amount_to_allocate = min(amount_can_pay, amount_to_pay)
 
         accounting_log.info(
             "Allocating {} from payment {} to invoice {}".format(
-                amount_to_allocate, self.date, invoice.number))
+                amount_to_allocate, self.date, invoice.number
+            )
+        )
 
-        PaymentAllocation.objects.create(invoice=invoice,
-                                         payment=self,
-                                         amount=amount_to_allocate)
+        PaymentAllocation.objects.create(
+            invoice=invoice, payment=self, amount=amount_to_allocate
+        )
 
         # Close invoice if relevant
         if (invoice.amount_remaining_to_pay() <= 0) and (invoice.status == "open"):
             accounting_log.info(
-                "Invoice {} has been paid and is now closed".format(
-                    invoice.number))
+                "Invoice {} has been paid and is now closed".format(invoice.number)
+            )
             invoice.status = "closed"
 
         invoice.save()
@@ -440,14 +503,14 @@ class Payment(models.Model):
 
     def __unicode__(self):
         if self.member is not None:
-            return 'Paiment de {:0.2f}€ le {} par {}'.format(
-                self.amount, self.date, self.member)
+            return "Paiment de {:0.2f}€ le {} par {}".format(
+                self.amount, self.date, self.member
+            )
         else:
-            return 'Paiment de {:0.2f}€ le {}'.format(
-                self.amount, self.date)
+            return "Paiment de {:0.2f}€ le {}".format(self.amount, self.date)
 
     class Meta:
-        verbose_name = 'paiement'
+        verbose_name = "paiement"
 
 
 # This corresponds to a (possibly partial) allocation of a given payment to
@@ -456,14 +519,23 @@ 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',
-                                null=False, blank=False,
-                                related_name='allocations')
-    payment = models.ForeignKey(Payment, verbose_name='facture associée',
-                                null=False, blank=False,
-                                related_name='allocations')
-    amount = models.DecimalField(max_digits=5, decimal_places=2, null=True,
-                                 verbose_name='montant')
+    invoice = models.ForeignKey(
+        Invoice,
+        verbose_name="facture associée",
+        null=False,
+        blank=False,
+        related_name="allocations",
+    )
+    payment = models.ForeignKey(
+        Payment,
+        verbose_name="facture associée",
+        null=False,
+        blank=False,
+        related_name="allocations",
+    )
+    amount = models.DecimalField(
+        max_digits=5, decimal_places=2, null=True, verbose_name="montant"
+    )
 
 
 def get_active_payment_and_invoices(member):
@@ -471,14 +543,18 @@ def get_active_payment_and_invoices(member):
     # Fetch relevant and active payments / invoices
     # and sort then by chronological order : olders first, newers last.
 
-    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_invoices = [
+        i for i in member.invoices.filter(validated=True).order_by("date")
+    ]
     this_member_payments = [p for p in member.payments.order_by("date")]
 
     # TODO / FIXME ^^^ maybe also consider only 'opened' invoices (i.e. not
     # conflict / trouble invoices)
 
-    active_payments = [p for p in this_member_payments if p.amount_not_allocated()    > 0]
-    active_invoices = [p for p in this_member_invoices if p.amount_remaining_to_pay() > 0]
+    active_payments = [p for p in this_member_payments if p.amount_not_allocated() > 0]
+    active_invoices = [
+        p for p in this_member_invoices if p.amount_remaining_to_pay() > 0
+    ]
 
     return active_payments, active_invoices
 
@@ -493,19 +569,20 @@ def update_accounting_for_member(member):
 
     accounting_log.info("Updating accounting for member {} ...".format(member))
     accounting_log.info(
-        "Member {} current balance is {} ...".format(member, member.balance))
+        "Member {} current balance is {} ...".format(member, member.balance)
+    )
 
     reconcile_invoices_and_payments(member)
 
-    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_invoices = [
+        i for i in member.invoices.filter(validated=True).order_by("date")
+    ]
     this_member_payments = [p for p in member.payments.order_by("date")]
 
-    member.balance = compute_balance(this_member_invoices,
-                                     this_member_payments)
+    member.balance = compute_balance(this_member_invoices, this_member_payments)
     member.save()
 
-    accounting_log.info("Member {} new balance is {:f}".format(
-        member, member.balance))
+    accounting_log.info("Member {} new balance is {:f}".format(member, member.balance))
 
 
 def reconcile_invoices_and_payments(member):
@@ -519,17 +596,19 @@ def reconcile_invoices_and_payments(member):
     if active_payments == []:
         accounting_log.info(
             "(No active payment for {}.".format(member)
-            + " No invoice/payment reconciliation needed.).")
+            + " No invoice/payment reconciliation needed.)."
+        )
         return
     elif active_invoices == []:
         accounting_log.info(
-            "(No active invoice for {}. No invoice/payment ".format(member) +
-            "reconciliation needed.).")
+            "(No active invoice for {}. No invoice/payment ".format(member)
+            + "reconciliation needed.)."
+        )
         return
 
     accounting_log.info(
-        "Initiating reconciliation between invoice and payments for {}".format(
-            member))
+        "Initiating reconciliation between invoice and payments for {}".format(member)
+    )
 
     while active_payments != [] and active_invoices != []:
 
@@ -542,8 +621,8 @@ def reconcile_invoices_and_payments(member):
             assert p.invoice in active_invoices
             i = p.invoice
             accounting_log.info(
-                "Payment is to be allocated specifically to invoice {}".format(
-                    i.number))
+                "Payment is to be allocated specifically to invoice {}".format(i.number)
+            )
         else:
             i = active_invoices[0]
 
@@ -563,12 +642,12 @@ def reconcile_invoices_and_payments(member):
 
 def compute_balance(invoices, payments):
 
-    active_payments = [p for p in payments if p.amount_not_allocated()    > 0]
+    active_payments = [p for p in payments if p.amount_not_allocated() > 0]
     active_invoices = [i for i in invoices if i.amount_remaining_to_pay() > 0]
 
     s = 0
     s -= sum([i.amount_remaining_to_pay() for i in active_invoices])
-    s += sum([p.amount_not_allocated()    for p in active_payments])
+    s += sum([p.amount_not_allocated() for p in active_payments])
 
     return s
 
@@ -578,19 +657,34 @@ def compute_balance(invoices, payments):
 def payment_changed(sender, instance, created, **kwargs):
 
     if created:
-        accounting_log.info("Adding payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
-                            % (instance.pk, instance.date, instance.member,
-                                instance.amount, instance.label))
+        accounting_log.info(
+            "Adding payment %s (Date: %s, Member: %s, Amount: %s, Label: %s)."
+            % (
+                instance.pk,
+                instance.date,
+                instance.member,
+                instance.amount,
+                instance.label,
+            )
+        )
     else:
-        accounting_log.info("Updating payment %s (Date: %s, Member: %s, Amount: %s, Label: %s, Allocated: %s)."
-                            % (instance.pk, instance.date, instance.member,
-                                instance.amount, instance.label,
-                                instance.amount_already_allocated()))
+        accounting_log.info(
+            "Updating payment %s (Date: %s, Member: %s, Amount: %s, Label: %s, Allocated: %s)."
+            % (
+                instance.pk,
+                instance.date,
+                instance.member,
+                instance.amount,
+                instance.label,
+                instance.amount_already_allocated(),
+            )
+        )
 
     # If this payment is related to a member, update the accounting for
     # this member
-    if (created or instance.amount_not_allocated() != 0) \
-    and (instance.member is not None):
+    if (created or instance.amount_not_allocated() != 0) and (
+        instance.member is not None
+    ):
         update_accounting_for_member(instance.member)
 
 
@@ -601,17 +695,26 @@ def invoice_changed(sender, instance, created, **kwargs):
     if created:
         accounting_log.info(
             "Creating draft invoice DRAFT-{} (Member: {}).".format(
-                instance.pk, instance.member))
+                instance.pk, instance.member
+            )
+        )
     else:
         if not instance.validated:
             accounting_log.info(
                 "Updating draft invoice DRAFT-{} (Member: {}).".format(
-                    instance.number, instance.member))
+                    instance.number, instance.member
+                )
+            )
         else:
             accounting_log.info(
                 "Updating invoice {} (Member: {}, Total amount: {}, Amount paid: {}).".format(
-                    instance.number, instance.member,
-                    instance.amount(), instance.amount_paid()))
+                    instance.number,
+                    instance.member,
+                    instance.amount(),
+                    instance.amount_paid(),
+                )
+            )
+
 
 @receiver(post_delete, sender=PaymentAllocation)
 def paymentallocation_deleted(sender, instance, **kwargs):
@@ -630,18 +733,19 @@ def payment_deleted(sender, instance, **kwargs):
 
     accounting_log.info(
         "Deleted payment {} (Date: {}, Member: {}, Amount: {}, Label: {}).".format(
-            instance.pk, instance.date, instance.member, instance.amount, instance.label))
+            instance.pk, instance.date, instance.member, instance.amount, instance.label
+        )
+    )
 
     member = instance.member
 
     if member is None:
         return
 
-    this_member_invoices = [i for i in member.invoices.filter(validated=True).order_by("date")]
+    this_member_invoices = [
+        i for i in member.invoices.filter(validated=True).order_by("date")
+    ]
     this_member_payments = [p for p in member.payments.order_by("date")]
 
-    member.balance = compute_balance(this_member_invoices,
-                                     this_member_payments)
+    member.balance = compute_balance(this_member_invoices, this_member_payments)
     member.save()
-
-

+ 164 - 131
coin/billing/tests.py

@@ -11,28 +11,34 @@ from coin.members.tests import MemberTestsUtils
 from coin.members.models import Member, LdapUser
 from coin.billing.models import Invoice, InvoiceQuerySet, InvoiceDetail, Payment
 from coin.offers.models import Offer, OfferSubscription
-from coin.billing.create_subscriptions_invoices import create_member_invoice_for_a_period
-from coin.billing.create_subscriptions_invoices import create_all_members_invoices_for_a_period
+from coin.billing.create_subscriptions_invoices import (
+    create_member_invoice_for_a_period
+)
+from coin.billing.create_subscriptions_invoices import (
+    create_all_members_invoices_for_a_period
+)
 
 
 @override_settings(HANDLE_BALANCE=True)
 class BillingInvoiceCreationTests(TestCase):
-
     def setUp(self):
         # Créé une offre
-        self.offer = Offer(name='Offre', billing_period=3, period_fees=30,
-                           initial_fees=50)
+        self.offer = Offer(
+            name="Offre", billing_period=3, period_fees=30, initial_fees=50
+        )
         self.offer.save()
         # Créé un membre
         self.username = MemberTestsUtils.get_random_username()
-        self.member = Member(first_name='Balthazar', last_name='Picsou',
-                             username=self.username)
+        self.member = Member(
+            first_name="Balthazar", last_name="Picsou", username=self.username
+        )
         self.member.save()
         # Créé un abonnement
         self.subscription = OfferSubscription(
             subscription_date=datetime.date(2014, 1, 10),
             member=self.member,
-            offer=self.offer)
+            offer=self.offer,
+        )
         self.subscription.save()
 
     def tearDown(self):
@@ -47,12 +53,13 @@ class BillingInvoiceCreationTests(TestCase):
         """
         # Demande la création de la première facture
         invoice = create_member_invoice_for_a_period(
-            self.member, datetime.date(2014, 1, 1))
+            self.member, datetime.date(2014, 1, 1)
+        )
         # La facture doit avoir les frais de mise en service
         # Pour tester cela on tri par montant d'item décroissant.
         # Comme dans l'offre créé, les initial_fees sont plus élevées que
         # les period_fees, il doit sortir en premier
-        self.assertEqual(invoice.details.order_by('-amount').first().amount, 50)
+        self.assertEqual(invoice.details.order_by("-amount").first().amount, 50)
 
     def test_prorata_for_first_month_subscription(self):
         """
@@ -61,18 +68,21 @@ class BillingInvoiceCreationTests(TestCase):
         """
         # Créé la facture pour le mois de janvier
         invoice = create_member_invoice_for_a_period(
-            self.member, datetime.date(2014, 1, 1))
+            self.member, datetime.date(2014, 1, 1)
+        )
         # Comme l'abonnement a été souscris le 10/01 et que la période de
         # facturation est de 3 mois, alors le prorata doit être :
         # janvier :  22j (31-9)
         # fevrier :  28j
         # mars :     31j
-        #22+28+31 / 31+28+31
+        # 22+28+31 / 31+28+31
         quantity = Decimal((22.0 + 28.0 + 31.0) / (31.0 + 28.0 + 31.0))
         for detail in invoice.details.all():
             if detail.amount != 50:
-                self.assertEqual(detail.quantity.quantize(Decimal('0.01')),
-                                 quantity.quantize(Decimal('0.01')))
+                self.assertEqual(
+                    detail.quantity.quantize(Decimal("0.01")),
+                    quantity.quantize(Decimal("0.01")),
+                )
 
     def test_subscription_cant_be_charged_twice(self):
         """
@@ -84,25 +94,30 @@ class BillingInvoiceCreationTests(TestCase):
         invoice.save()
         # Créé une facturation pour cet abonnement pour la première période
         # de janvier à mars
-        invoice.details.create(label=self.offer.name,
-                               amount=self.offer.period_fees,
-                               offersubscription=self.subscription,
-                               period_from=datetime.date(2014, 1, 1),
-                               period_to=datetime.date(2014, 3, 31))
+        invoice.details.create(
+            label=self.offer.name,
+            amount=self.offer.period_fees,
+            offersubscription=self.subscription,
+            period_from=datetime.date(2014, 1, 1),
+            period_to=datetime.date(2014, 3, 31),
+        )
 
         # Créé une facturation pour cet abonnement pour une seconde période
         # de juin à aout
-        invoice.details.create(label=self.offer.name,
-                               amount=self.offer.period_fees,
-                               offersubscription=self.subscription,
-                               period_from=datetime.date(2014, 6, 1),
-                               period_to=datetime.date(2014, 8, 31))
+        invoice.details.create(
+            label=self.offer.name,
+            amount=self.offer.period_fees,
+            offersubscription=self.subscription,
+            period_from=datetime.date(2014, 6, 1),
+            period_to=datetime.date(2014, 8, 31),
+        )
 
         # Demande la génération d'une facture pour février
         # Elle doit renvoyer None car l'offre est déjà facturée de
         # janvier à mars
         invoice_test_1 = create_member_invoice_for_a_period(
-            self.member, datetime.date(2014, 2, 1))
+            self.member, datetime.date(2014, 2, 1)
+        )
         self.assertEqual(invoice_test_1, None)
 
         # Demande la création d'une facture pour avril
@@ -110,29 +125,36 @@ class BillingInvoiceCreationTests(TestCase):
         # que de 2 mois, d'avril à mai car il y a déjà une facture pour
         # la période de juin à aout
         invoice_test_2 = create_member_invoice_for_a_period(
-            self.member, datetime.date(2014, 4, 1))
-        self.assertEqual(invoice_test_2.details.first().period_from,
-                         datetime.date(2014, 4, 1))
-        self.assertEqual(invoice_test_2.details.first().period_to,
-                         datetime.date(2014, 5, 31))
+            self.member, datetime.date(2014, 4, 1)
+        )
+        self.assertEqual(
+            invoice_test_2.details.first().period_from, datetime.date(2014, 4, 1)
+        )
+        self.assertEqual(
+            invoice_test_2.details.first().period_to, datetime.date(2014, 5, 31)
+        )
 
     def test_invoice_amount(self):
         invoice = Invoice(member=self.member)
         invoice.save()
 
-        invoice.details.create(label=self.offer.name,
-                               amount=100,
-                               offersubscription=self.subscription,
-                               period_from=datetime.date(2014, 1, 1),
-                               period_to=datetime.date(2014, 3, 31),
-                               tax=0)
-
-        invoice.details.create(label=self.offer.name,
-                               amount=10,
-                               offersubscription=self.subscription,
-                               period_from=datetime.date(2014, 6, 1),
-                               period_to=datetime.date(2014, 8, 31),
-                               tax=10)
+        invoice.details.create(
+            label=self.offer.name,
+            amount=100,
+            offersubscription=self.subscription,
+            period_from=datetime.date(2014, 1, 1),
+            period_to=datetime.date(2014, 3, 31),
+            tax=0,
+        )
+
+        invoice.details.create(
+            label=self.offer.name,
+            amount=10,
+            offersubscription=self.subscription,
+            period_from=datetime.date(2014, 6, 1),
+            period_to=datetime.date(2014, 8, 31),
+            tax=10,
+        )
 
         self.assertEqual(invoice.amount(), 111)
 
@@ -140,51 +162,55 @@ class BillingInvoiceCreationTests(TestCase):
         invoice = Invoice(member=self.member)
         invoice.save()
 
-        invoice.details.create(label=self.offer.name,
-                               amount=100,
-                               offersubscription=self.subscription,
-                               period_from=datetime.date(2014, 1, 1),
-                               period_to=datetime.date(2014, 3, 31),
-                               tax=0)
+        invoice.details.create(
+            label=self.offer.name,
+            amount=100,
+            offersubscription=self.subscription,
+            period_from=datetime.date(2014, 1, 1),
+            period_to=datetime.date(2014, 3, 31),
+            tax=0,
+        )
         invoice.validate()
         invoice.save()
 
-        self.assertEqual(invoice.status, 'open')
-        p1 = Payment.objects.create(member=self.member,
-                                    invoice=invoice,
-                                    payment_mean='cash',
-                                    amount=10)
+        self.assertEqual(invoice.status, "open")
+        p1 = Payment.objects.create(
+            member=self.member, invoice=invoice, payment_mean="cash", amount=10
+        )
         p1.save()
 
         invoice = Invoice.objects.get(pk=invoice.pk)
-        self.assertEqual(invoice.status, 'open')
+        self.assertEqual(invoice.status, "open")
 
-        p2 = Payment.objects.create(member=self.member,
-                                    invoice=invoice,
-                                    payment_mean='cash',
-                                    amount=90)
+        p2 = Payment.objects.create(
+            member=self.member, invoice=invoice, payment_mean="cash", amount=90
+        )
         p2.save()
 
         invoice = Invoice.objects.get(pk=invoice.pk)
-        self.assertEqual(invoice.status, 'closed')
+        self.assertEqual(invoice.status, "closed")
 
     def test_invoice_amount_before_tax(self):
         invoice = Invoice(member=self.member)
         invoice.save()
 
-        invoice.details.create(label=self.offer.name,
-                               amount=100,
-                               offersubscription=self.subscription,
-                               period_from=datetime.date(2014, 1, 1),
-                               period_to=datetime.date(2014, 3, 31),
-                               tax=0)
-
-        invoice.details.create(label=self.offer.name,
-                               amount=10,
-                               offersubscription=self.subscription,
-                               period_from=datetime.date(2014, 6, 1),
-                               period_to=datetime.date(2014, 8, 31),
-                               tax=10)
+        invoice.details.create(
+            label=self.offer.name,
+            amount=100,
+            offersubscription=self.subscription,
+            period_from=datetime.date(2014, 1, 1),
+            period_to=datetime.date(2014, 3, 31),
+            tax=0,
+        )
+
+        invoice.details.create(
+            label=self.offer.name,
+            amount=10,
+            offersubscription=self.subscription,
+            period_from=datetime.date(2014, 6, 1),
+            period_to=datetime.date(2014, 8, 31),
+            tax=10,
+        )
 
         self.assertEqual(invoice.amount_before_tax(), 110)
 
@@ -193,19 +219,26 @@ class BillingInvoiceCreationTests(TestCase):
         Test qu'une offre non facturable n'est pas prise en compte
         """
         # Créé une offre non facturable
-        offer = Offer(name='Offre', billing_period=3, period_fees=30,
-                           initial_fees=50, non_billable=True)
+        offer = Offer(
+            name="Offre",
+            billing_period=3,
+            period_fees=30,
+            initial_fees=50,
+            non_billable=True,
+        )
         offer.save()
         # Créé un abonnement
         self.subscription = OfferSubscription(
             subscription_date=datetime.date(2014, 1, 10),
             member=self.member,
-            offer=offer)
+            offer=offer,
+        )
         self.subscription.save()
 
         # Demande la création de la première facture
         invoice = create_member_invoice_for_a_period(
-            self.member, datetime.date(2014, 1, 1))
+            self.member, datetime.date(2014, 1, 1)
+        )
 
         # Vérifie qu'il n'y a pas l'offre dans la facture, si c'est le cas génère une exception
         if invoice:
@@ -215,7 +248,6 @@ class BillingInvoiceCreationTests(TestCase):
 
 
 class BillingTests(TestCase):
-
     def test_download_invoice_pdf_return_a_pdf(self):
         """
         Test que le téléchargement d'une facture en format pdf retourne bien un
@@ -223,9 +255,8 @@ class BillingTests(TestCase):
         """
         # Créé un membre
         username = MemberTestsUtils.get_random_username()
-        member = Member(first_name='A', last_name='A',
-                        username=username)
-        member.set_password('1234')
+        member = Member(first_name="A", last_name="A", username=username)
+        member.set_password("1234")
         member.save()
 
         # Créé une facture
@@ -235,11 +266,11 @@ class BillingTests(TestCase):
 
         # Se connect en tant que le membre
         client = Client()
-        client.login(username=username, password='1234')
+        client.login(username=username, password="1234")
         # Tente de télécharger la facture
-        response = client.get('/billing/invoice/%i/pdf' % invoice.id)
+        response = client.get("/billing/invoice/%i/pdf" % invoice.id)
         # Vérifie return code 200 et contient chaine %PDF-1.
-        self.assertContains(response, b'%PDF-1.', status_code=200, html=False)
+        self.assertContains(response, b"%PDF-1.", status_code=200, html=False)
         member.delete()
 
     def test_that_only_owner_of_invoice_can_access_it(self):
@@ -251,17 +282,19 @@ class BillingTests(TestCase):
         """
         # Créé un membre A
         member_a_login = MemberTestsUtils.get_random_username()
-        member_a_pwd = '1234'
-        member_a = Member(first_name='A', last_name='A', email='a@a.com',
-                          username=member_a_login)
+        member_a_pwd = "1234"
+        member_a = Member(
+            first_name="A", last_name="A", email="a@a.com", username=member_a_login
+        )
         member_a.set_password(member_a_pwd)
         member_a.save()
 
         # Créé un membre B
         member_b_login = MemberTestsUtils.get_random_username()
-        member_b_pwd = '1234'
-        member_b = Member(first_name='B', last_name='B', email='b@b.com',
-                          username=member_b_login)
+        member_b_pwd = "1234"
+        member_b = Member(
+            first_name="B", last_name="B", email="b@b.com", username=member_b_login
+        )
         member_b.set_password(member_b_pwd)
         member_b.save()
 
@@ -278,7 +311,7 @@ class BillingTests(TestCase):
         # Vérifie que A a reçu retour OK 200
         self.assertEqual(response.status_code, 200)
         # Tente de télécharger la facture pdf de A en tant que A
-        response = client.get('/billing/invoice/%i/pdf' % invoice_a.id)
+        response = client.get("/billing/invoice/%i/pdf" % invoice_a.id)
         # Vérifie que A a reçu retour OK 200
         self.assertEqual(response.status_code, 200)
 
@@ -290,7 +323,7 @@ class BillingTests(TestCase):
         # Vérifie que B a reçu retour Forbissen 403
         self.assertEqual(response.status_code, 403)
         # Tente de télécharger la facture pdf de A en tant que B
-        response = client.get('/billing/invoice/%i/pdf' % invoice_a.id)
+        response = client.get("/billing/invoice/%i/pdf" % invoice_a.id)
         # Vérifie que B a reçu retour Forbidden 403
         self.assertEqual(response.status_code, 403)
 
@@ -301,89 +334,89 @@ class BillingTests(TestCase):
 class InvoiceQuerySetTests(TestCase):
     def test_get_first_invoice_number_ever(self):
         self.assertEqual(
-            Invoice.objects.get_next_invoice_number(datetime.date(2016,1,1)),
-            '2016-01-000001')
+            Invoice.objects.get_next_invoice_number(datetime.date(2016, 1, 1)),
+            "2016-01-000001",
+        )
 
-    @freeze_time('2016-01-01')
+    @freeze_time("2016-01-01")
     def test_get_first_of_month_invoice_number(self):
         # One bill on a month…
         Invoice.objects.create().validate()
 
         # … Does not affect the numbering of following month.
         self.assertEqual(
-            Invoice.objects.get_next_invoice_number(datetime.date(2016,2,15)),
-            '2016-02-000001')
+            Invoice.objects.get_next_invoice_number(datetime.date(2016, 2, 15)),
+            "2016-02-000001",
+        )
 
-    @freeze_time('2016-01-01')
+    @freeze_time("2016-01-01")
     def test_number_workflow(self):
         iv = Invoice.objects.create()
-        self.assertEqual(iv.number, 'DRAFT-{}'.format(iv.pk))
+        self.assertEqual(iv.number, "DRAFT-{}".format(iv.pk))
         iv.validate()
-        self.assertRegexpMatches(iv.number, r'2016-01-000001$')
+        self.assertRegexpMatches(iv.number, r"2016-01-000001$")
 
-    @freeze_time('2016-01-01')
+    @freeze_time("2016-01-01")
     def test_get_second_of_month_invoice_number(self):
-        first_bill = Invoice.objects.create(date=datetime.date(2016,1,1))
+        first_bill = Invoice.objects.create(date=datetime.date(2016, 1, 1))
         first_bill.validate()
         self.assertEqual(
-            Invoice.objects.get_next_invoice_number(datetime.date(2016,1,1)),
-            '2016-01-000002')
+            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'):
+        with freeze_time("2016-01-01"):
             Invoice.objects.create(date=datetime.date(2016, 1, 1)).validate()
-        with freeze_time('2017-01-01'):
+        with freeze_time("2017-01-01"):
             Invoice.objects.create(date=datetime.date(2017, 1, 1)).validate()
-        with freeze_time('2018-01-01'):
+        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')
+            "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))
+        bill = Invoice.objects.create(date=datetime.date(2016, 1, 1))
+        self.assertEqual(bill.date, datetime.date(2016, 1, 1))
 
-        with freeze_time('2017-01-01'):
+        with freeze_time("2017-01-01"):
             bill.validate()
             self.assertEqual(bill.date, datetime.date(2017, 1, 1))
-            self.assertEqual(bill.number, '2017-01-000001')
+            self.assertEqual(bill.number, "2017-01-000001")
 
 
 class PaymentInvoiceAutoReconciliationTests(TestCase):
-
     def test_accounting_update(self):
 
-        johndoe =  Member.objects.create(username=MemberTestsUtils.get_random_username(),
-                                         first_name="John",
-                                         last_name="Doe",
-                                         email="johndoe@yolo.test")
+        johndoe = Member.objects.create(
+            username=MemberTestsUtils.get_random_username(),
+            first_name="John",
+            last_name="Doe",
+            email="johndoe@yolo.test",
+        )
         johndoe.set_password("trololo")
 
         # First facture
-        invoice = Invoice.objects.create(number="1337",
-                                         member=johndoe)
-        InvoiceDetail.objects.create(label="superservice",
-                                     amount="15.0",
-                                     invoice=invoice)
+        invoice = Invoice.objects.create(number="1337", member=johndoe)
+        InvoiceDetail.objects.create(
+            label="superservice", amount="15.0", invoice=invoice
+        )
         invoice.validate()
 
         # Second facture
-        invoice2 = Invoice.objects.create(number="42",
-                                         member=johndoe)
-        InvoiceDetail.objects.create(label="superservice",
-                                     amount="42",
-                                     invoice=invoice2)
+        invoice2 = Invoice.objects.create(number="42", member=johndoe)
+        InvoiceDetail.objects.create(
+            label="superservice", amount="42", invoice=invoice2
+        )
         invoice2.validate()
 
         # Payment
-        payment = Payment.objects.create(amount=20,
-                                         member=johndoe)
+        payment = Payment.objects.create(amount=20, member=johndoe)
 
         invoice.delete()
         invoice2.delete()
         payment.delete()
         johndoe.delete()
-
-

+ 4 - 5
coin/billing/urls.py

@@ -6,10 +6,9 @@ from django.views.generic import DetailView
 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"^invoice/(?P<id>.+)/pdf$", views.invoice_pdf, name="invoice_pdf"),
+    url(r"^invoice/(?P<id>.+)$", views.invoice, name="invoice"),
     # url(r'^invoice/(?P<id>.+)/validate$', views.invoice_validate, name="invoice_validate"),
-
-    url('invoice/create_all_members_invoices_for_a_period', views.gen_invoices)
+    url("invoice/create_all_members_invoices_for_a_period", views.gen_invoices),
 )

+ 2 - 3
coin/billing/utils.py

@@ -21,6 +21,5 @@ def assert_user_can_view_the_invoice(request, invoice):
     """
     Raise PermissionDenied if logged user can't access given invoice
     """
-    if not invoice.has_owner(request.user.username)\
-       and not request.user.is_superuser:
-        raise PermissionDenied
+    if not invoice.has_owner(request.user.username) and not request.user.is_superuser:
+        raise PermissionDenied

+ 17 - 8
coin/billing/views.py

@@ -11,13 +11,18 @@ from sendfile import sendfile
 from coin.billing.models import Invoice
 from coin.members.models import Member
 from coin.html2pdf import render_as_pdf
-from coin.billing.create_subscriptions_invoices import create_all_members_invoices_for_a_period
-from coin.billing.utils import get_invoice_from_id_or_number, assert_user_can_view_the_invoice
+from coin.billing.create_subscriptions_invoices import (
+    create_all_members_invoices_for_a_period
+)
+from coin.billing.utils import (
+    get_invoice_from_id_or_number,
+    assert_user_can_view_the_invoice,
+)
 
 
 def gen_invoices(request):
     create_all_members_invoices_for_a_period()
-    return HttpResponse('blop')
+    return HttpResponse("blop")
 
 
 def invoice_pdf(request, id):
@@ -29,10 +34,11 @@ def invoice_pdf(request, id):
 
     assert_user_can_view_the_invoice(request, invoice)
 
-    pdf_filename = 'Facture_%s.pdf' % invoice.number
+    pdf_filename = "Facture_%s.pdf" % invoice.number
 
-    return sendfile(request, invoice.pdf.path,
-                    attachment=True, attachment_filename=pdf_filename)
+    return sendfile(
+        request, invoice.pdf.path, attachment=True, attachment_filename=pdf_filename
+    )
 
 
 def invoice(request, id):
@@ -44,7 +50,10 @@ def invoice(request, id):
 
     assert_user_can_view_the_invoice(request, invoice)
 
-    return render_to_response('billing/invoice.html', {"invoice": invoice},
-                              context_instance=RequestContext(request))
+    return render_to_response(
+        "billing/invoice.html",
+        {"invoice": invoice},
+        context_instance=RequestContext(request),
+    )
 
     return response

+ 14 - 4
coin/configuration/admin.py

@@ -14,6 +14,7 @@ ConfigurationAdminFormMixin. This make use of ConfigurationForm form that
 filter offersubscription select input to avoid selecting wrong subscription.
 """
 
+
 class IPSubnetInline(admin.TabularInline):
     model = IPSubnet
     extra = 0
@@ -22,11 +23,17 @@ class IPSubnetInline(admin.TabularInline):
 class ParentConfigurationAdmin(PolymorphicParentModelAdmin):
     base_model = Configuration
     polymorphic_list = True
-    list_display = ('model_name','configuration_type_name', 'offersubscription', 'offer_subscription_member')
+    list_display = (
+        "model_name",
+        "configuration_type_name",
+        "offersubscription",
+        "offer_subscription_member",
+    )
 
     def offer_subscription_member(self, config):
         return config.offersubscription.member
-    offer_subscription_member.short_description = 'Membre'
+
+    offer_subscription_member.short_description = "Membre"
 
     def get_child_models(self):
         """
@@ -34,7 +41,9 @@ class ParentConfigurationAdmin(PolymorphicParentModelAdmin):
         ex :((VPNConfiguration, VPNConfigurationAdmin),
             (ADSLConfiguration, ADSLConfigurationAdmin))
         """
-        return (tuple((x.base_model, x) for x in PolymorphicChildModelAdmin.__subclasses__()))
+        return tuple(
+            (x.base_model, x) for x in PolymorphicChildModelAdmin.__subclasses__()
+        )
 
     def get_urls(self):
         """
@@ -57,6 +66,7 @@ class ConfigurationAdminFormMixin(object):
     base_form = ConfigurationForm
     # For each child (admin object for configurations), this will display
     # an inline form to assign IP addresses.
-    inlines = (IPSubnetInline, )
+    inlines = (IPSubnetInline,)
+
 
 admin.site.register(Configuration, ParentConfigurationAdmin)

+ 11 - 7
coin/configuration/forms.py

@@ -9,10 +9,9 @@ from coin.configuration.models import Configuration
 
 
 class ConfigurationForm(ModelForm):
-
     class Meta:
         model = Configuration
-        fields = '__all__'
+        fields = "__all__"
 
     def __init__(self, *args, **kwargs):
         """
@@ -23,17 +22,22 @@ class ConfigurationForm(ModelForm):
         super(ConfigurationForm, self).__init__(*args, **kwargs)
         if self.instance:
             queryset = OfferSubscription.objects.filter(
-                Q(offer__configuration_type=self.instance.model_name) & (
-                Q(configuration=None) | Q(configuration=self.instance.pk)))
-            self.fields['offersubscription'].queryset = queryset
+                Q(offer__configuration_type=self.instance.model_name)
+                & (Q(configuration=None) | Q(configuration=self.instance.pk))
+            )
+            self.fields["offersubscription"].queryset = queryset
 
     def clean_offersubscription(self):
         """
         This check if the selected administrative subscription is linked to an
         offer which use the same configuration type than the edited configuration.
         """
-        offersubscription = self.cleaned_data['offersubscription']
+        offersubscription = self.cleaned_data["offersubscription"]
         if offersubscription.offer.configuration_type != self.instance.model_name():
-            raise ValidationError('Administrative subscription must refer an offer having a "{}" configuration type.'.format(self.instance.model_name()))
+            raise ValidationError(
+                'Administrative subscription must refer an offer having a "{}" configuration type.'.format(
+                    self.instance.model_name()
+                )
+            )
 
         return offersubscription

+ 20 - 16
coin/configuration/models.py

@@ -21,13 +21,13 @@ Your model can implement Meta verbose_name to have human readable name and a
 url_namespace variable to specify the url namespace used by this model.
 """
 
+
 class Configuration(PolymorphicModel):
 
-    offersubscription = models.OneToOneField(OfferSubscription,
-                                             related_name='configuration',
-                                             verbose_name='abonnement')
-    comment = models.CharField(blank=True, max_length=512,
-                               verbose_name="commentaire")
+    offersubscription = models.OneToOneField(
+        OfferSubscription, related_name="configuration", verbose_name="abonnement"
+    )
+    comment = models.CharField(blank=True, max_length=512, verbose_name="commentaire")
 
     @staticmethod
     def get_configurations_choices_list():
@@ -35,16 +35,20 @@ class Configuration(PolymorphicModel):
         Génère automatiquement la liste de choix possibles de configurations
         en fonction des classes enfants de Configuration
         """
-        return tuple((x().__class__.__name__,x()._meta.verbose_name) 
-            for x in Configuration.__subclasses__())
-    
+        return tuple(
+            (x().__class__.__name__, x()._meta.verbose_name)
+            for x in Configuration.__subclasses__()
+        )
+
     def model_name(self):
         return self.__class__.__name__
-    model_name.short_description = 'Nom du modèle'
+
+    model_name.short_description = "Nom du modèle"
 
     def configuration_type_name(self):
         return self._meta.verbose_name
-    configuration_type_name.short_description = 'Type'
+
+    configuration_type_name.short_description = "Type"
 
     def get_absolute_url(self):
         """
@@ -52,8 +56,8 @@ class Configuration(PolymorphicModel):
         Une url doit être nommée "details"
         """
         from django.core.urlresolvers import reverse
-        return reverse('%s:details' % self.get_url_namespace(), 
-                       args=[str(self.id)])
+
+        return reverse("%s:details" % self.get_url_namespace(), args=[str(self.id)])
 
     def get_url_namespace(self):
         """
@@ -61,13 +65,13 @@ class Configuration(PolymorphicModel):
         celui définit dans la classe enfant dans url_namespace sinon
         par défaut utilise le nom de la classe en minuscule
         """
-        if hasattr(self, 'url_namespace') and self.url_namespace:
+        if hasattr(self, "url_namespace") and self.url_namespace:
             return self.url_namespace
         else:
             return self.model_name().lower()
 
     class Meta:
-        verbose_name = 'configuration'
+        verbose_name = "configuration"
 
 
 @receiver(post_save, sender=IPSubnet)
@@ -100,10 +104,10 @@ def subnet_event(sender, **kwargs):
     raised, which is great (even if undocumented).
 
     """
-    subnet = kwargs['instance']
+    subnet = kwargs["instance"]
     try:
         config = subnet.configuration
-        if hasattr(config, 'subnet_event'):
+        if hasattr(config, "subnet_event"):
             config.subnet_event()
     except ObjectDoesNotExist:
         pass

+ 1 - 1
coin/context_processors.py

@@ -4,4 +4,4 @@ from django.conf import settings
 def installed_apps(request):
     """ Expose the settings INSTALLED_APPS to templates
     """
-    return {'INSTALLED_APPS': settings.INSTALLED_APPS}
+    return {"INSTALLED_APPS": settings.INSTALLED_APPS}

+ 9 - 9
coin/filtering_queryset.py

@@ -7,6 +7,7 @@ import logging
 
 logger = logging.getLogger(__name__)
 
+
 class LimitedAdminInlineMixin(object):
     """
     InlineAdmin mixin limiting the selection of related items according to
@@ -30,16 +31,16 @@ class LimitedAdminInlineMixin(object):
         `field` and filters it based on the criteria specified in filters,
         unless `empty=True`. In this case, no choices will be made available.
         """
-        try:        
+        try:
             assert formset.form.base_fields.has_key(field)
 
             qs = formset.form.base_fields[field].queryset
             if empty:
-                logger.debug('Limiting the queryset to none')
+                logger.debug("Limiting the queryset to none")
                 formset.form.base_fields[field].queryset = qs.none()
             else:
                 qs = qs.filter(**filters)
-                logger.debug('Limiting queryset for formset to: %s', qs)
+                logger.debug("Limiting queryset for formset to: %s", qs)
 
                 formset.form.base_fields[field].queryset = qs
         except:
@@ -50,16 +51,15 @@ class LimitedAdminInlineMixin(object):
         Make sure we can only select variations that relate to the current
         item.
         """
-        formset = \
-            super(LimitedAdminInlineMixin, self).get_formset(request,
-                                                             obj,
-                                                             **kwargs)
+        formset = super(LimitedAdminInlineMixin, self).get_formset(
+            request, obj, **kwargs
+        )
         for (field, filters) in self.get_filters(obj):
             if obj and filters:
                 self.limit_inline_choices(formset, field, **filters)
             else:
                 self.limit_inline_choices(formset, field, empty=True)
-        
+
         return formset
 
     def get_filters(self, obj):
@@ -73,4 +73,4 @@ class LimitedAdminInlineMixin(object):
         subclass or define a `filters` property with the same syntax as this
         one.
         """
-        return getattr(self, 'filters', ())
+        return getattr(self, "filters", ())

+ 6 - 8
coin/html2pdf.py

@@ -16,10 +16,10 @@ def link_callback(uri, rel):
     # Convert HTML URIs to absolute system paths so xhtml2pdf can access
     # those resources
     """
-    sUrl = settings.STATIC_URL    # Typically /static/
+    sUrl = settings.STATIC_URL  # Typically /static/
     sRoot = settings.STATIC_ROOT  # Typically /home/userX/project_static/
-    mUrl = settings.MEDIA_URL     # Typically /static/media/
-    mRoot = settings.MEDIA_ROOT   # Typically /home/userX/project_static/media/
+    mUrl = settings.MEDIA_URL  # Typically /static/media/
+    mRoot = settings.MEDIA_ROOT  # Typically /home/userX/project_static/media/
     projectDir = settings.PROJECT_PATH  # Typically /home/userX/project/
 
     # convert URIs to absolute system paths
@@ -28,20 +28,18 @@ def link_callback(uri, rel):
     elif uri.startswith(sUrl):
         path = os.path.join(sRoot, uri.replace(sUrl, ""))
     else:
-        return uri # handle the absolute URIs
+        return uri  # handle the absolute URIs
 
     # If file doesn't exist try to find it in app static folder
     # This case occur in developpement env
     if not os.path.isfile(path):
-        app_search = re.search(r'^(%s|%s)(.*)/.*' % (sUrl, mUrl), uri)
+        app_search = re.search(r"^(%s|%s)(.*)/.*" % (sUrl, mUrl), uri)
         app = app_search.group(2)
         path = os.path.join(projectDir, app, uri[1:])
 
     # make sure that file exists
     if not os.path.isfile(path):
-        raise Exception(
-            'media URI must start with %s or %s' %
-            (sUrl, mUrl))
+        raise Exception("media URI must start with %s or %s" % (sUrl, mUrl))
     return path
 
 

+ 76 - 37
coin/isp_database/admin.py

@@ -6,20 +6,29 @@ from django.forms import ModelForm
 
 from localflavor.fr.forms import FRPhoneNumberField
 
-from coin.isp_database.models import ISPInfo, RegisteredOffice, OtherWebsite, ChatRoom, CoveredArea, BankInfo
+from coin.isp_database.models import (
+    ISPInfo,
+    RegisteredOffice,
+    OtherWebsite,
+    ChatRoom,
+    CoveredArea,
+    BankInfo,
+)
 
-class ISPAdminForm(ModelForm):
 
+class ISPAdminForm(ModelForm):
     class Meta:
         model = ISPInfo
         exclude = []
 
-    phone_number = FRPhoneNumberField(required=False,
-                                      help_text='Main contact phone number')
+    phone_number = FRPhoneNumberField(
+        required=False, help_text="Main contact phone number"
+    )
 
 
 class SingleInstanceAdminMixin(object):
     """Hides the "Add" button when there is already an instance"""
+
     def has_add_permission(self, request):
         num_objects = self.model.objects.count()
         if num_objects >= 1:
@@ -31,15 +40,24 @@ class RegisteredOfficeInline(admin.StackedInline):
     model = RegisteredOffice
     extra = 0
     fieldsets = (
-        ('', {'fields': (
-             ('street_address', 'extended_address', 'post_office_box'),
-             ('postal_code', 'locality'),
-             ('region', 'country_name'))}),
-        ('Extras', {
-             'fields': ('siret',),
-             'description': 'Ces champs ne font pas partie de la spécification db.ffdn.org mais sont utilisés sur le site'})
-        )
-
+        (
+            "",
+            {
+                "fields": (
+                    ("street_address", "extended_address", "post_office_box"),
+                    ("postal_code", "locality"),
+                    ("region", "country_name"),
+                )
+            },
+        ),
+        (
+            "Extras",
+            {
+                "fields": ("siret",),
+                "description": "Ces champs ne font pas partie de la spécification db.ffdn.org mais sont utilisés sur le site",
+            },
+        ),
+    )
 
 
 class OtherWebsiteInline(admin.StackedInline):
@@ -61,40 +79,61 @@ class BankInfoInline(admin.StackedInline):
     model = BankInfo
     extra = 0
 
-    fieldsets = (('', {
-                'fields': ('iban', 'bic', 'bank_name', 'check_order'),
-                'description': (
-                    'Les coordonnées bancaires ne font pas partie de la'+
-                    ' spécification db.ffdn.org mais sont utilisées par le'+
-                    ' site (facturation notamment).')
-    }),)
+    fieldsets = (
+        (
+            "",
+            {
+                "fields": ("iban", "bic", "bank_name", "check_order"),
+                "description": (
+                    "Les coordonnées bancaires ne font pas partie de la"
+                    + " spécification db.ffdn.org mais sont utilisées par le"
+                    + " site (facturation notamment)."
+                ),
+            },
+        ),
+    )
 
 
 class ISPInfoAdmin(SingleInstanceAdminMixin, admin.ModelAdmin):
     model = ISPInfo
     fieldsets = (
-        ('General', {'fields': (
-            ('name', 'shortname'),
-            'description',
-            'logoURL',
-            ('creationDate', 'ffdnMemberSince'),
-            'progressStatus',
-            ('latitude', 'longitude'))}),
-        ('Contact', {'fields': (
-            ('email', 'mainMailingList'),
-            'website', 'phone_number')}),
-        ('Extras', {
-            'fields': ('administrative_email', 'support_email', 'lists_url'),
-            'description':
-                'Ces champs ne font pas partie de la spécification db.ffdn.org mais sont utilisés sur le site'
-        }),
+        (
+            "General",
+            {
+                "fields": (
+                    ("name", "shortname"),
+                    "description",
+                    "logoURL",
+                    ("creationDate", "ffdnMemberSince"),
+                    "progressStatus",
+                    ("latitude", "longitude"),
+                )
+            },
+        ),
+        (
+            "Contact",
+            {"fields": (("email", "mainMailingList"), "website", "phone_number")},
+        ),
+        (
+            "Extras",
+            {
+                "fields": ("administrative_email", "support_email", "lists_url"),
+                "description": "Ces champs ne font pas partie de la spécification db.ffdn.org mais sont utilisés sur le site",
+            },
+        ),
     )
 
-    inlines = (RegisteredOfficeInline, BankInfoInline, OtherWebsiteInline, ChatRoomInline,
-               CoveredAreaInline)
+    inlines = (
+        RegisteredOfficeInline,
+        BankInfoInline,
+        OtherWebsiteInline,
+        ChatRoomInline,
+        CoveredAreaInline,
+    )
     save_on_top = True
 
     # Use custom form
     form = ISPAdminForm
 
+
 admin.site.register(ISPInfo, ISPInfoAdmin)

+ 2 - 1
coin/isp_database/context_processors.py

@@ -3,7 +3,8 @@ from __future__ import unicode_literals
 
 from coin.isp_database.models import ISPInfo
 
+
 def branding(request):
     """ Just a shortcut to get the ISP object in templates
     """
-    return {'branding': ISPInfo.objects.first()}
+    return {"branding": ISPInfo.objects.first()}

+ 153 - 86
coin/isp_database/models.py

@@ -18,11 +18,13 @@ from coin.validation import chatroom_url_validator
 # API version, see http://db.ffdn.org/format
 API_VERSION = 0.1
 
-TECHNOLOGIES = (('ftth', 'FTTH'),
-                ('dsl', '*DSL'),
-                ('wifi', 'WiFi'),
-                ('vpn', 'VPN'),
-                ('cube', 'Brique Internet'))
+TECHNOLOGIES = (
+    ("ftth", "FTTH"),
+    ("dsl", "*DSL"),
+    ("wifi", "WiFi"),
+    ("vpn", "VPN"),
+    ("cube", "Brique Internet"),
+)
 
 
 class SingleInstanceMixin(object):
@@ -30,7 +32,7 @@ class SingleInstanceMixin(object):
 
     def clean(self):
         model = self.__class__
-        if (model.objects.count() > 0 and self.id != model.objects.get().id):
+        if model.objects.count() > 0 and self.id != model.objects.get().id:
             raise ValidationError("Can only create 1 instance of %s" % model.__name__)
         super(SingleInstanceMixin, self).clean()
 
@@ -41,6 +43,7 @@ class ISPInfo(SingleInstanceMixin, models.Model):
     The naming convention is different from Python/django so that it
     matches exactly the format (which uses CamelCase...)
     """
+
     # These two properties can be overriden with static counters, see below.
     @property
     def memberCount(self):
@@ -52,70 +55,102 @@ class ISPInfo(SingleInstanceMixin, models.Model):
         """Number of subscribers to an internet access"""
         return count_active_subscriptions()
 
-    name = models.CharField(max_length=512,
-                            verbose_name="Nom",
-                            help_text="Nom du FAI")
+    name = models.CharField(max_length=512, verbose_name="Nom", help_text="Nom du FAI")
     # Length required by the spec
-    shortname = models.CharField(max_length=15, blank=True,
-                                 verbose_name="Abréviation",
-                                 help_text="Nom plus court")
-    description = models.TextField(blank=True,
-                                   verbose_name="Description",
-                                   help_text="Description courte du projet")
-    logoURL = models.URLField(blank=True,
-                              verbose_name="URL du logo",
-                              help_text="Adresse HTTP(S) du logo du FAI")
-    website = models.URLField(blank=True,
-                              verbose_name="URL du site Internet",
-                              help_text='Adresse URL du site Internet')
-    email = models.EmailField(verbose_name="Courriel",
-                              help_text="Adresse courriel de contact")
-    mainMailingList = models.EmailField(blank=True,
-                                        verbose_name="Liste de discussion principale",
-                                        help_text="Principale liste de discussion publique")
-    phone_number = models.CharField(max_length=25, blank=True,
-                                    verbose_name="Numéro de téléphone",
-                                    help_text='Numéro de téléphone de contact principal')
-    creationDate = models.DateField(blank=True, null=True,
-                                    verbose_name="Date de création",
-                                    help_text="Date de création de la structure légale")
-    ffdnMemberSince = models.DateField(blank=True, null=True,
-                                       verbose_name="Membre de FFDN depuis",
-                                       help_text="Date à laquelle le FAI a rejoint la Fédération FDN")
+    shortname = models.CharField(
+        max_length=15,
+        blank=True,
+        verbose_name="Abréviation",
+        help_text="Nom plus court",
+    )
+    description = models.TextField(
+        blank=True, verbose_name="Description", help_text="Description courte du projet"
+    )
+    logoURL = models.URLField(
+        blank=True,
+        verbose_name="URL du logo",
+        help_text="Adresse HTTP(S) du logo du FAI",
+    )
+    website = models.URLField(
+        blank=True,
+        verbose_name="URL du site Internet",
+        help_text="Adresse URL du site Internet",
+    )
+    email = models.EmailField(
+        verbose_name="Courriel", help_text="Adresse courriel de contact"
+    )
+    mainMailingList = models.EmailField(
+        blank=True,
+        verbose_name="Liste de discussion principale",
+        help_text="Principale liste de discussion publique",
+    )
+    phone_number = models.CharField(
+        max_length=25,
+        blank=True,
+        verbose_name="Numéro de téléphone",
+        help_text="Numéro de téléphone de contact principal",
+    )
+    creationDate = models.DateField(
+        blank=True,
+        null=True,
+        verbose_name="Date de création",
+        help_text="Date de création de la structure légale",
+    )
+    ffdnMemberSince = models.DateField(
+        blank=True,
+        null=True,
+        verbose_name="Membre de FFDN depuis",
+        help_text="Date à laquelle le FAI a rejoint la Fédération FDN",
+    )
     # TODO: choice field
     progressStatus = models.PositiveSmallIntegerField(
         validators=[MaxValueValidator(7)],
-        blank=True, null=True, verbose_name="État d'avancement",
-        help_text="État d'avancement du FAI")
+        blank=True,
+        null=True,
+        verbose_name="État d'avancement",
+        help_text="État d'avancement du FAI",
+    )
     # TODO: better model for coordinates
-    latitude = models.FloatField(blank=True, null=True,
-                                 verbose_name="Latitude",
-                                 help_text="Coordonnées latitudinales du siège")
-    longitude = models.FloatField(blank=True, null=True,
-                                  verbose_name="Longitude",
-                                  help_text="Coordonnées longitudinales du siège")
+    latitude = models.FloatField(
+        blank=True,
+        null=True,
+        verbose_name="Latitude",
+        help_text="Coordonnées latitudinales du siège",
+    )
+    longitude = models.FloatField(
+        blank=True,
+        null=True,
+        verbose_name="Longitude",
+        help_text="Coordonnées longitudinales du siège",
+    )
 
     # Uncomment this (and handle the necessary migrations) if you want to
     # manage one of the counters by hand.  Otherwise, they are computed
     # automatically, which is probably what you want.
-    #memberCount = models.PositiveIntegerField(help_text="Nombre de membres",
+    # memberCount = models.PositiveIntegerField(help_text="Nombre de membres",
     #                                          default=0)
-    #subscriberCount = models.PositiveIntegerField(
+    # subscriberCount = models.PositiveIntegerField(
     #    help_text="Nombre d'abonnés à un accès Internet",
     #    default=0)
 
     # field outside of db-ffdn format:
     administrative_email = models.EmailField(
-        blank=True, verbose_name="contact administratif",
-        help_text='Adresse email pour les contacts administratifs (ex: bureau)')
+        blank=True,
+        verbose_name="contact administratif",
+        help_text="Adresse email pour les contacts administratifs (ex: bureau)",
+    )
 
     support_email = models.EmailField(
-        blank=True, verbose_name="contact de support",
-        help_text="Adresse email pour les demandes de support technique")
+        blank=True,
+        verbose_name="contact de support",
+        help_text="Adresse email pour les demandes de support technique",
+    )
 
     lists_url = models.URLField(
-        verbose_name="serveur de listes", blank=True,
-        help_text="URL du serveur de listes de discussions/diffusion")
+        verbose_name="serveur de listes",
+        blank=True,
+        help_text="URL du serveur de listes de discussions/diffusion",
+    )
 
     class Meta:
         verbose_name = "Information du FAI"
@@ -132,45 +167,53 @@ class ISPInfo(SingleInstanceMixin, models.Model):
         if first_chatroom:
             m = utils.re_chat_url.match(first_chatroom.url)
             if m:
-                return '{channel} sur {server}'.format(**(m.groupdict()))
+                return "{channel} sur {server}".format(**(m.groupdict()))
 
         return None
 
     def get_absolute_url(self):
-        return '/isp.json'
+        return "/isp.json"
 
     def to_dict(self):
         data = dict()
         # These are required
-        for f in ('version', 'name', 'email', 'memberCount', 'subscriberCount'):
+        for f in ("version", "name", "email", "memberCount", "subscriberCount"):
             data[f] = getattr(self, f)
 
         # These are optional
-        for f in ('shortname', 'description', 'logoURL', 'website',
-                  'mainMailingList', 'progressStatus'):
+        for f in (
+            "shortname",
+            "description",
+            "logoURL",
+            "website",
+            "mainMailingList",
+            "progressStatus",
+        ):
             if getattr(self, f):
                 data[f] = getattr(self, f)
 
         # Dates
-        for d in ('creationDate', 'ffdnMemberSince'):
+        for d in ("creationDate", "ffdnMemberSince"):
             if getattr(self, d):
                 data[d] = getattr(self, d).isoformat()
 
         # Hackish for now
         if self.latitude or self.longitude:
-            data['coordinates'] = { "latitude": self.latitude,
-                                    "longitude": self.longitude }
+            data["coordinates"] = {
+                "latitude": self.latitude,
+                "longitude": self.longitude,
+            }
 
         # Related objects
-        data['coveredAreas'] = [c.to_dict() for c in self.coveredarea_set.all()]
+        data["coveredAreas"] = [c.to_dict() for c in self.coveredarea_set.all()]
         otherwebsites = self.otherwebsite_set.all()
         if otherwebsites:
-            data['otherWebsites'] = { site.name: site.url for site in otherwebsites }
+            data["otherWebsites"] = {site.name: site.url for site in otherwebsites}
         chatrooms = self.chatroom_set.all()
         if chatrooms:
-            data['chatrooms'] = [chatroom.url for chatroom in chatrooms]
-        if hasattr(self, 'registeredoffice'):
-            data['registeredOffice'] = self.registeredoffice.to_dict()
+            data["chatrooms"] = [chatroom.url for chatroom in chatrooms]
+        if hasattr(self, "registeredoffice"):
+            data["registeredOffice"] = self.registeredoffice.to_dict()
 
         return data
 
@@ -190,17 +233,26 @@ class OtherWebsite(models.Model):
 
 class RegisteredOffice(models.Model):
     """ http://json-schema.org/address """
-    post_office_box = models.CharField(max_length=512, blank=True, verbose_name="Boîte postale")
-    extended_address = models.CharField(max_length=512, blank=True, verbose_name="Adresse complémentaire")
-    street_address = models.CharField(max_length=512, blank=True, verbose_name="Adresse")
+
+    post_office_box = models.CharField(
+        max_length=512, blank=True, verbose_name="Boîte postale"
+    )
+    extended_address = models.CharField(
+        max_length=512, blank=True, verbose_name="Adresse complémentaire"
+    )
+    street_address = models.CharField(
+        max_length=512, blank=True, verbose_name="Adresse"
+    )
     locality = models.CharField(max_length=512, verbose_name="Ville")
     region = models.CharField(max_length=512, verbose_name="Région")
-    postal_code = models.CharField(max_length=512, blank=True, verbose_name="Code postal")
+    postal_code = models.CharField(
+        max_length=512, blank=True, verbose_name="Code postal"
+    )
     country_name = models.CharField(max_length=512, verbose_name="Pays")
     isp = models.OneToOneField(ISPInfo)
 
     # not in db.ffdn.org spec
-    siret = FRSIRETField('SIRET')
+    siret = FRSIRETField("SIRET")
 
     class Meta:
         verbose_name = "Siège social"
@@ -208,17 +260,25 @@ class RegisteredOffice(models.Model):
 
     def to_dict(self):
         d = dict()
-        for field in ('post_office_box', 'extended_address', 'street_address',
-                      'locality', 'region', 'postal_code', 'country_name'):
+        for field in (
+            "post_office_box",
+            "extended_address",
+            "street_address",
+            "locality",
+            "region",
+            "postal_code",
+            "country_name",
+        ):
             if getattr(self, field):
-                key = field.replace('_', '-')
+                key = field.replace("_", "-")
                 d[key] = getattr(self, field)
         return d
 
 
 class ChatRoom(models.Model):
     url = models.CharField(
-        verbose_name="URL", max_length=256, validators=[chatroom_url_validator])
+        verbose_name="URL", max_length=256, validators=[chatroom_url_validator]
+    )
     isp = models.ForeignKey(ISPInfo)
 
     class Meta:
@@ -229,14 +289,15 @@ class ChatRoom(models.Model):
 class CoveredArea(models.Model):
     name = models.CharField(max_length=512, verbose_name="Nom")
 
-    technologies = MultiSelectField(choices=TECHNOLOGIES, max_length=42, verbose_name="Technologie")
+    technologies = MultiSelectField(
+        choices=TECHNOLOGIES, max_length=42, verbose_name="Technologie"
+    )
     # TODO: find a geojson library
-    #area =
+    # area =
     isp = models.ForeignKey(ISPInfo)
 
     def to_dict(self):
-        return {"name": self.name,
-                "technologies": self.technologies}
+        return {"name": self.name, "technologies": self.technologies}
 
     class Meta:
         verbose_name = "Zone couverte"
@@ -248,17 +309,23 @@ class BankInfo(models.Model):
 
     This is out of the scope of db.ffdn.org spec.
     """
+
     isp = models.OneToOneField(ISPInfo)
-    iban = IBANField('IBAN')
-    bic = BICField('BIC', blank=True, null=True)
-    bank_name = models.CharField('établissement bancaire',
-                                 max_length=100, blank=True, null=True)
-    check_order = models.CharField('ordre',
-                                   max_length=100, blank=False, null=False,
-                                   help_text='Ordre devant figurer sur un \
+    iban = IBANField("IBAN")
+    bic = BICField("BIC", blank=True, null=True)
+    bank_name = models.CharField(
+        "établissement bancaire", max_length=100, blank=True, null=True
+    )
+    check_order = models.CharField(
+        "ordre",
+        max_length=100,
+        blank=False,
+        null=False,
+        help_text="Ordre devant figurer sur un \
                                    chèque bancaire à destination de\
-                                   l\'association')
+                                   l'association",
+    )
 
     class Meta:
-        verbose_name = 'coordonnées bancaires'
+        verbose_name = "coordonnées bancaires"
         verbose_name_plural = verbose_name

+ 6 - 4
coin/isp_database/templatetags/isptags.py

@@ -2,11 +2,13 @@ from django.template import Template, Library
 
 register = Library()
 
-@register.inclusion_tag('isp_database/includes/isp_address_multiline.html')
+
+@register.inclusion_tag("isp_database/includes/isp_address_multiline.html")
 def multiline_isp_addr(branding):
-    return {'branding': branding}
+    return {"branding": branding}
+
 
 @register.filter
 def pretty_iban(s):
-    #FR764 2559 0001 2410 2002 3285 19
-    return ' '.join([s[i:i+4] for i in xrange(0, len(s), 4)])
+    # FR764 2559 0001 2410 2002 3285 19
+    return " ".join([s[i : i + 4] for i in xrange(0, len(s), 4)])

+ 15 - 12
coin/isp_database/tests.py

@@ -7,6 +7,7 @@ from coin.members.models import Member
 from coin.isp_database.templatetags.isptags import *
 from .models import ChatRoom, ISPInfo
 
+
 class TestPrettifiers(TestCase):
     def test_pretty_iban(self):
         """ Prints pretty readable IBAN
@@ -14,28 +15,30 @@ class TestPrettifiers(TestCase):
         Takes the IBAN in compact form and displays it according to the display spec
         See http://en.wikipedia.org/wiki/International_Bank_Account_Number#Practicalities
         """
-        self.assertEqual(pretty_iban('DEkkBBBBBBBBCCCCCCCCCC'),
-                         'DEkk BBBB BBBB CCCC CCCC CC')
-        self.assertEqual(pretty_iban('ADkkBBBBSSSSCCCCCCCCCCCC'),
-                         'ADkk BBBB SSSS CCCC CCCC CCCC')
-        self.assertEqual(pretty_iban(''), '')
+        self.assertEqual(
+            pretty_iban("DEkkBBBBBBBBCCCCCCCCCC"), "DEkk BBBB BBBB CCCC CCCC CC"
+        )
+        self.assertEqual(
+            pretty_iban("ADkkBBBBSSSSCCCCCCCCCCCC"), "ADkk BBBB SSSS CCCC CCCC CCCC"
+        )
+        self.assertEqual(pretty_iban(""), "")
+
 
 class TestContactPage(TestCase):
     def setUp(self):
         # Could be replaced by a force_login when we will be at Django 1.9
-        Member.objects.create_user('user', password='password')
-        self.client.login(username='user', password='password')
+        Member.objects.create_user("user", password="password")
+        self.client.login(username="user", password="password")
 
     def test_chat_view(self):
-        isp = ISPInfo.objects.create(name='test', email='foo@example.com', )
+        isp = ISPInfo.objects.create(name="test", email="foo@example.com")
 
         # Without chatroom
-        response = self.client.get('/members/contact/')
+        response = self.client.get("/members/contact/")
         self.assertEqual(response.status_code, 200)
 
         # With chatroom
-        ChatRoom.objects.create(
-            isp=isp, url='irc://irc.example.com/#chan')
+        ChatRoom.objects.create(isp=isp, url="irc://irc.example.com/#chan")
 
-        response = self.client.get('/members/contact/')
+        response = self.client.get("/members/contact/")
         self.assertEqual(response.status_code, 200)

+ 160 - 92
coin/members/admin.py

@@ -15,7 +15,14 @@ from django.core.urlresolvers import reverse
 from django.utils.html import format_html
 
 from coin.members.models import (
-    Member, CryptoKey, LdapUser, MembershipFee, Offer, OfferSubscription, RowLevelPermission)
+    Member,
+    CryptoKey,
+    LdapUser,
+    MembershipFee,
+    Offer,
+    OfferSubscription,
+    RowLevelPermission,
+)
 from coin.members.membershipfee_filter import MembershipFeeFilter
 from coin.members.forms import AdminMemberChangeForm, MemberCreationForm
 from coin.utils import delete_selected
@@ -30,16 +37,22 @@ class CryptoKeyInline(admin.StackedInline):
 class MembershipFeeInline(admin.TabularInline):
     model = MembershipFee
     extra = 0
-    fields = ('start_date', 'end_date', 'amount', 'payment_method',
-              'reference', 'payment_date')
+    fields = (
+        "start_date",
+        "end_date",
+        "amount",
+        "payment_method",
+        "reference",
+        "payment_date",
+    )
 
 
 class OfferSubscriptionInline(admin.TabularInline):
     model = OfferSubscription
     extra = 0
 
-    writable_fields = ('subscription_date', 'resign_date', 'commitment', 'offer')
-    all_fields = ('get_subscription_reference',) + writable_fields
+    writable_fields = ("subscription_date", "resign_date", "commitment", "offer")
+    all_fields = ("get_subscription_reference",) + writable_fields
 
     def get_fields(self, request, obj=None):
         if obj:
@@ -50,7 +63,7 @@ class OfferSubscriptionInline(admin.TabularInline):
     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',)
+            return ("get_subscription_reference",)
         # modification : lecture seule seulement
         else:
             return self.all_fields
@@ -59,11 +72,15 @@ class OfferSubscriptionInline(admin.TabularInline):
 
     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)
+            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)
+            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
@@ -72,9 +89,8 @@ class OfferSubscriptionInline(admin.TabularInline):
         #   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
+            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
@@ -84,59 +100,90 @@ class OfferSubscriptionInline(admin.TabularInline):
 
 
 class MemberAdmin(UserAdmin):
-    list_display = ('id', 'status', 'username', 'first_name', 'last_name',
-                    'nickname', 'organization_name', 'email',
-                    'end_date_of_membership')
-    list_display_links = ('id', 'username', 'first_name', 'last_name')
-    list_filter = ('status', MembershipFeeFilter)
-    search_fields = ['username', 'first_name', 'last_name', 'email', 'nickname']
-    ordering = ('status', 'username')
-    actions = [delete_selected, 'set_as_member', 'set_as_non_member',
-               'bulk_send_welcome_email', 'bulk_send_call_for_membership_fee_email']
+    list_display = (
+        "id",
+        "status",
+        "username",
+        "first_name",
+        "last_name",
+        "nickname",
+        "organization_name",
+        "email",
+        "end_date_of_membership",
+    )
+    list_display_links = ("id", "username", "first_name", "last_name")
+    list_filter = ("status", MembershipFeeFilter)
+    search_fields = ["username", "first_name", "last_name", "email", "nickname"]
+    ordering = ("status", "username")
+    actions = [
+        delete_selected,
+        "set_as_member",
+        "set_as_non_member",
+        "bulk_send_welcome_email",
+        "bulk_send_call_for_membership_fee_email",
+    ]
 
     form = AdminMemberChangeForm
     add_form = MemberCreationForm
 
     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'))})
-        auth_fieldset = ('Authentification', {'fields': (
-            ('username', 'password'))})
-        perm_fieldset = ('Permissions', {'fields': (
-            ('is_active', 'is_staff', 'is_superuser', 'groups'))})
+        coord_fieldset = (
+            "Coordonnées",
+            {
+                "fields": (
+                    ("email", "send_membership_fees_email"),
+                    ("home_phone_number", "mobile_phone_number"),
+                    "address",
+                    ("postal_code", "city", "country"),
+                )
+            },
+        )
+        auth_fieldset = ("Authentification", {"fields": (("username", "password"))})
+        perm_fieldset = (
+            "Permissions",
+            {"fields": (("is_active", "is_staff", "is_superuser", "groups"))},
+        )
 
         # if obj is null then it is a creation, otherwise it is a modification
         if obj:
             fieldsets = (
-                ('Adhérent', {'fields': (
-                    ('status', 'date_joined', 'resign_date'),
-                    'type',
-                    ('first_name', 'last_name', 'nickname'),
-                    'organization_name',
-                    'comments'
-                )}),
+                (
+                    "Adhérent",
+                    {
+                        "fields": (
+                            ("status", "date_joined", "resign_date"),
+                            "type",
+                            ("first_name", "last_name", "nickname"),
+                            "organization_name",
+                            "comments",
+                        )
+                    },
+                ),
                 coord_fieldset,
                 auth_fieldset,
                 perm_fieldset,
-                (None, {'fields': ('date_last_call_for_membership_fees_email',)})
+                (None, {"fields": ("date_last_call_for_membership_fees_email",)}),
             )
         else:
             fieldsets = (
-                ('Adhérent', {'fields': (
-                    ('status', 'date_joined'),
-                    'type',
-                    ('first_name', 'last_name', 'nickname'),
-                    'organization_name',
-                    'comments')}),
+                (
+                    "Adhérent",
+                    {
+                        "fields": (
+                            ("status", "date_joined"),
+                            "type",
+                            ("first_name", "last_name", "nickname"),
+                            "organization_name",
+                            "comments",
+                        )
+                    },
+                ),
                 coord_fieldset,
                 auth_fieldset,
-                perm_fieldset
+                perm_fieldset,
             )
         if settings.HANDLE_BALANCE:
-            fieldsets[0][1]['fields'] += ('balance',)
+            fieldsets[0][1]["fields"] += ("balance",)
         return fieldsets
 
     radio_fields = {"type": admin.HORIZONTAL}
@@ -158,36 +205,45 @@ class MemberAdmin(UserAdmin):
         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 = ''
+            username_field = [f for f in obj._meta.fields if f.name == "username"]
+            username_field[0].help_text = ""
 
-            readonly_fields.append('username')
+            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']
+            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')
+        rows_updated = queryset.update(status="member")
         self.message_user(
-            request,
-            '%d membre(s) définis comme adhérent(s).' % rows_updated)
-    set_as_member.short_description = 'Définir comme adhérent'
+            request, "%d membre(s) définis comme adhérent(s)." % rows_updated
+        )
+
+    set_as_member.short_description = "Définir comme adhérent"
 
     def set_as_non_member(self, request, queryset):
-        rows_updated = queryset.update(status='not_member')
+        rows_updated = queryset.update(status="not_member")
         self.message_user(
-            request,
-            '%d membre(s) définis comme non adhérent(s).' % rows_updated)
+            request, "%d membre(s) définis comme non adhérent(s)." % rows_updated
+        )
+
     set_as_non_member.short_description = "Définir comme non adhérent"
 
     def get_urls(self):
         """Custom admin urls"""
         urls = super(MemberAdmin, self).get_urls()
         my_urls = [
-            url(r'^send_welcome_email/(?P<id>\d+)$',
+            url(
+                r"^send_welcome_email/(?P<id>\d+)$",
                 self.admin_site.admin_view(self.send_welcome_email),
-                name='send_welcome_email'),
+                name="send_welcome_email",
+            )
         ]
         return my_urls + urls
 
@@ -200,15 +256,18 @@ class MemberAdmin(UserAdmin):
         if request.user.is_superuser:
             member = get_object_or_404(Member, pk=id)
             member.send_welcome_email()
-            messages.success(request,
-                             'Le courriel de bienvenue a été envoyé à %s' % member.email)
+            messages.success(
+                request, "Le courriel de bienvenue a été envoyé à %s" % member.email
+            )
         else:
             messages.error(
-                request, 'Vous n\'avez pas l\'autorisation d\'envoyer des '
-                         'courriels de bienvenue.')
+                request,
+                "Vous n'avez pas l'autorisation d'envoyer des "
+                "courriels de bienvenue.",
+            )
 
         if return_httpredirect:
-            return HttpResponseRedirect(reverse('admin:members_member_changelist'))
+            return HttpResponseRedirect(reverse("admin:members_member_changelist"))
 
     def bulk_send_welcome_email(self, request, queryset):
         """
@@ -216,18 +275,21 @@ class MemberAdmin(UserAdmin):
         depuis une sélection de membre dans la vue liste de l'admin
         """
         for member in queryset.all():
-            self.send_welcome_email(
-                request, member.id, return_httpredirect=False)
-        messages.success(request,
-                         'Le courriel de bienvenue a été envoyé à %d membre(s).' % queryset.count())
+            self.send_welcome_email(request, member.id, return_httpredirect=False)
+        messages.success(
+            request,
+            "Le courriel de bienvenue a été envoyé à %d membre(s)." % queryset.count(),
+        )
+
     bulk_send_welcome_email.short_description = "Envoyer le courriel de bienvenue"
 
     def bulk_send_call_for_membership_fee_email(self, request, queryset):
         # TODO : Add better perm here
         if not request.user.is_superuser:
             messages.error(
-                request, 'Vous n\'avez pas l\'autorisation d\'envoyer des '
-                         'courriels de relance.')
+                request,
+                "Vous n'avez pas l'autorisation d'envoyer des " "courriels de relance.",
+            )
             return
         cpt_success = 0
         for member in queryset.all():
@@ -235,38 +297,44 @@ class MemberAdmin(UserAdmin):
             if member.send_call_for_membership_fees_email():
                 cpt_success += 1
             else:
-                messages.warning(request,
-                              "Le courriel de relance de cotisation n\'a pas "
-                              "été envoyé à {member} ({email}) car il a déjà "
-                              "reçu une relance le {last_call_date}"\
-                              .format(member=member,
-                                     email=member.email,
-                                     last_call_date=member.date_last_call_for_membership_fees_email))
+                messages.warning(
+                    request,
+                    "Le courriel de relance de cotisation n'a pas "
+                    "été envoyé à {member} ({email}) car il a déjà "
+                    "reçu une relance le {last_call_date}".format(
+                        member=member,
+                        email=member.email,
+                        last_call_date=member.date_last_call_for_membership_fees_email,
+                    ),
+                )
 
         if queryset.count() == 1 and cpt_success == 1:
             member = queryset.first()
-            messages.success(request,
-                             "Le courriel de relance de cotisation a été "
-                             "envoyé à {member} ({email})"\
-                             .format(member=member, email=member.email))
-        elif cpt_success>1:
-            messages.success(request,
-                             "Le courriel de relance de cotisation a été "
-                             "envoyé à {cpt} membres"\
-                             .format(cpt=cpt_success))
+            messages.success(
+                request,
+                "Le courriel de relance de cotisation a été "
+                "envoyé à {member} ({email})".format(member=member, email=member.email),
+            )
+        elif cpt_success > 1:
+            messages.success(
+                request,
+                "Le courriel de relance de cotisation a été "
+                "envoyé à {cpt} membres".format(cpt=cpt_success),
+            )
 
-    bulk_send_call_for_membership_fee_email.short_description = 'Envoyer le courriel de relance de cotisation'
+    bulk_send_call_for_membership_fee_email.short_description = (
+        "Envoyer le courriel de relance de cotisation"
+    )
 
 
 class MembershipFeeAdmin(admin.ModelAdmin):
-    list_display = ('member', 'end_date', 'amount', 'payment_method',
-                    'payment_date')
-    form = autocomplete_light.modelform_factory(MembershipFee, fields='__all__')
+    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)}
-
+        return {"content_type": ContentType.objects.get_for_model(OfferSubscription)}
 
 
 admin.site.register(Member, MemberAdmin)

+ 17 - 12
coin/members/autocomplete_light_registry.py

@@ -5,16 +5,21 @@ import autocomplete_light
 from models import Member
 
 # This will generate a MemberAutocomplete class
-autocomplete_light.register(Member,
-                            # Just like in ModelAdmin.search_fields
-                            search_fields=[
-                                '^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,
-                            },
+autocomplete_light.register(
+    Member,
+    # Just like in ModelAdmin.search_fields
+    search_fields=[
+        "^first_name",
+        "^last_name",
+        "organization_name",
+        "^username",
+        "^nickname",
+    ],
+    attrs={
+        # This will set the input placeholder attribute:
+        "placeholder": "Nom/Prénom/Pseudo (min 3 caractères)",
+        # Nombre minimum de caractères à saisir avant de compléter.
+        # Fixé à 3 pour ne pas qu'on puisse avoir accès à la liste de tous les membres facilement quand on n'est pas superuser.
+        "data-autocomplete-minimum-characters": 3,
+    },
 )

+ 50 - 19
coin/members/forms.py

@@ -14,18 +14,26 @@ class MemberCreationForm(forms.ModelForm):
     This form was inspired from django.contrib.auth.forms.UserCreationForm
     and adapted to coin specificities
     """
-    username = forms.RegexField(required=False,
-                                label="Nom d'utilisateur", max_length=30, regex=r"^[\w.@+-]+$",
-                                help_text="Laisser vide pour le générer automatiquement à partir du "
-                                "nom d'usage, nom et prénom, ou nom de l'organisme")
+
+    username = forms.RegexField(
+        required=False,
+        label="Nom d'utilisateur",
+        max_length=30,
+        regex=r"^[\w.@+-]+$",
+        help_text="Laisser vide pour le générer automatiquement à partir du "
+        "nom d'usage, nom et prénom, ou nom de l'organisme",
+    )
     password = forms.CharField(
-        required=False, label='Mot de passe', widget=forms.PasswordInput,
+        required=False,
+        label="Mot de passe",
+        widget=forms.PasswordInput,
         help_text="Laisser vide et envoyer un mail de bienvenue pour que "
-        "l'utilisateur choisisse son mot de passe lui-même")
+        "l'utilisateur choisisse son mot de passe lui-même",
+    )
 
     class Meta:
         model = Member
-        fields = '__all__'
+        fields = "__all__"
 
     def save(self, commit=True):
         """
@@ -46,13 +54,13 @@ class AbstractMemberChangeForm(forms.ModelForm):
 
     class Meta:
         model = Member
-        fields = '__all__'
+        fields = "__all__"
 
     def __init__(self, *args, **kwargs):
         super(AbstractMemberChangeForm, self).__init__(*args, **kwargs)
-        f = self.fields.get('user_permissions', None)
+        f = self.fields.get("user_permissions", None)
         if f is not None:
-            f.queryset = f.queryset.select_related('content_type')
+            f.queryset = f.queryset.select_related("content_type")
 
     def clean_password(self):
         # Regardless of what the user provides, return the initial value.
@@ -72,21 +80,35 @@ class AdminMemberChangeForm(AbstractMemberChangeForm):
 class SpanError(ErrorList):
     def __unicode__(self):
         return self.as_spans()
+
     def __str__(self):
         return self.as_spans()
+
     def as_spans(self):
-        if not self: return ''
-        return ''.join(['<span class="error">%s</span>' % e for e in self])
+        if not self:
+            return ""
+        return "".join(['<span class="error">%s</span>' % e for e in self])
+
 
 class PersonMemberChangeForm(AbstractMemberChangeForm):
     """
     Form use to allow natural person to change their info
     """
+
     class Meta:
         model = Member
-        fields = ['first_name', 'last_name', 'email', 'nickname',
-                  'home_phone_number', 'mobile_phone_number',
-                  'address', 'postal_code', 'city', 'country']
+        fields = [
+            "first_name",
+            "last_name",
+            "email",
+            "nickname",
+            "home_phone_number",
+            "mobile_phone_number",
+            "address",
+            "postal_code",
+            "city",
+            "country",
+        ]
 
     def __init__(self, *args, **kwargs):
         super(PersonMemberChangeForm, self).__init__(*args, **kwargs)
@@ -99,11 +121,20 @@ class OrganizationMemberChangeForm(AbstractMemberChangeForm):
     """
     Form use to allow organization to change their info
     """
+
     class Meta:
         model = Member
-        fields = ['organization_name', 'email', 'nickname',
-                  'home_phone_number', 'mobile_phone_number',
-                  'address', 'postal_code', 'city', 'country']
+        fields = [
+            "organization_name",
+            "email",
+            "nickname",
+            "home_phone_number",
+            "mobile_phone_number",
+            "address",
+            "postal_code",
+            "city",
+            "country",
+        ]
 
     def __init__(self, *args, **kwargs):
         super(OrganizationChangeForm, self).__init__(*args, **kwargs)
@@ -111,6 +142,6 @@ class OrganizationMemberChangeForm(AbstractMemberChangeForm):
         for fieldname in self.fields:
             self.fields[fieldname].help_text = None
 
+
 class MemberPasswordResetForm(PasswordResetForm):
     pass
-

+ 34 - 23
coin/members/management/commands/call_for_membership_fees.py

@@ -12,7 +12,7 @@ from coin.members.models import Member, MembershipFee
 
 
 class Command(BaseCommand):
-    args = '[date=2011-07-04]'
+    args = "[date=2011-07-04]"
     help = """Send a call for membership email to members.
               A mail is sent when end date of membership
               reach the anniversary date, 1 month before and once a month
@@ -21,43 +21,54 @@ class Command(BaseCommand):
               can be passed as argument."""
 
     def handle(self, *args, **options):
-        verbosity = int(options['verbosity'])
+        verbosity = int(options["verbosity"])
         try:
-            date = datetime.datetime.strptime(args[0], '%Y-%m-%d').date()
+            date = datetime.datetime.strptime(args[0], "%Y-%m-%d").date()
         except IndexError:
             date = datetime.date.today()
         except ValueError:
             raise CommandError(
-                'Please enter a valid date : YYYY-mm-dd (ex: 2011-07-04)')
+                "Please enter a valid date : YYYY-mm-dd (ex: 2011-07-04)"
+            )
 
-        end_dates = [date + relativedelta(months=-3),
-                     date + relativedelta(months=-2),
-                     date + relativedelta(months=-1),
-                     date,
-                     date + relativedelta(months=+1)]
+        end_dates = [
+            date + relativedelta(months=-3),
+            date + relativedelta(months=-2),
+            date + relativedelta(months=-1),
+            date,
+            date + relativedelta(months=+1),
+        ]
 
         if verbosity >= 2:
-            self.stdout.write("Selecting members whose membership fee end at "
-                              "the following dates : {dates}".format(
-                                  dates=[str(d) for d in end_dates]))
-
-        members = Member.objects.filter(status='member')\
-                                .annotate(end=Max('membership_fees__end_date'))\
-                                .filter(end__in=end_dates)\
-                                .filter(send_membership_fees_email=True)
-        if verbosity >= 2:
             self.stdout.write(
-                "Got {number} members.".format(number=members.count()))
+                "Selecting members whose membership fee end at "
+                "the following dates : {dates}".format(
+                    dates=[str(d) for d in end_dates]
+                )
+            )
+
+        members = (
+            Member.objects.filter(status="member")
+            .annotate(end=Max("membership_fees__end_date"))
+            .filter(end__in=end_dates)
+            .filter(send_membership_fees_email=True)
+        )
+        if verbosity >= 2:
+            self.stdout.write("Got {number} members.".format(number=members.count()))
 
         cpt = 0
         with respect_language(settings.LANGUAGE_CODE):
             for member in members:
                 if member.send_call_for_membership_fees_email(auto=True):
                     self.stdout.write(
-                        'Call for membership fees email was sent to {member} ({email})'.format(
-                            member=member, email=member.email))
+                        "Call for membership fees email was sent to {member} ({email})".format(
+                            member=member, email=member.email
+                        )
+                    )
                     cpt = cpt + 1
 
         if cpt > 0 or verbosity >= 2:
-            self.stdout.write("{number} call for membership fees emails were "
-                              "sent".format(number=cpt))
+            self.stdout.write(
+                "{number} call for membership fees emails were "
+                "sent".format(number=cpt)
+            )

+ 23 - 17
coin/members/management/commands/members_email.py

@@ -10,49 +10,55 @@ from coin.members.models import Member
 from coin.offers.models import Offer
 from coin.offers.models import OfferSubscription
 
+
 class Command(BaseCommand):
     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')
+        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):
-        if options['subscribers']:
+        if options["subscribers"]:
             today = datetime.date.today()
-                        
+
             offer_subscriptions = OfferSubscription.objects.filter(
-                Q(resign_date__gt=today)
-                | Q(resign_date__isnull=True)
+                Q(resign_date__gt=today) | Q(resign_date__isnull=True)
             )
             members = [s.member for s in offer_subscriptions]
-        elif options['offer']:
+        elif options["offer"]:
             try:
                 # Try to find the offer by its reference
-                offer = Offer.objects.get(reference=options['offer'])
+                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_id = int(options["offer"])
                     offer = Offer.objects.get(pk=offer_id)
                 except Offer.DoesNotExist:
-                    raise CommandError('Offer "%s" does not exist' % options['offer'])
+                    raise CommandError('Offer "%s" does not exist' % options["offer"])
                 except (IndexError, ValueError):
-                    raise CommandError('Please enter a valid offer reference or id')
+                    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
+                # 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')
+            ).select_related("member")
             members = [s.member for s in offer_subscriptions]
         else:
-            members = Member.objects.filter(status='member')
+            members = Member.objects.filter(status="member")
 
-        emails = list(set([m.email for m in members if m.status == 'member']))
+        emails = list(set([m.email for m in members if m.status == "member"]))
         for email in emails:
             self.stdout.write(email)

+ 10 - 11
coin/members/membershipfee_filter.py

@@ -8,10 +8,10 @@ import datetime
 class MembershipFeeFilter(SimpleListFilter):
     # Human-readable title which will be displayed in the
     # right admin sidebar just above the filter options.
-    title = 'Cotisations'
+    title = "Cotisations"
 
     # Parameter for the filter that will be used in the URL query.
-    parameter_name = 'fee'
+    parameter_name = "fee"
 
     def lookups(self, request, model_admin):
         """
@@ -21,10 +21,7 @@ class MembershipFeeFilter(SimpleListFilter):
         human-readable name for the option that will appear
         in the right sidebar.
         """
-        return (
-            ('paidup', 'À jour de cotisation'),
-            ('late', 'En retard'),
-        )
+        return (("paidup", "À jour de cotisation"), ("late", "En retard"))
 
     def queryset(self, request, queryset):
         """
@@ -32,11 +29,13 @@ class MembershipFeeFilter(SimpleListFilter):
         provided in the query string and retrievable via
         `self.value()`.
         """
-        if self.value() == 'paidup':
+        if self.value() == "paidup":
             return queryset.filter(
                 membership_fees__start_date__lte=datetime.date.today,
-                membership_fees__end_date__gte=datetime.date.today)
-        if self.value() == 'late':
-            return queryset.filter(status='member').exclude(
+                membership_fees__end_date__gte=datetime.date.today,
+            )
+        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)
+                membership_fees__end_date__gte=datetime.date.today,
+            )

+ 241 - 170
coin/members/models.py

@@ -23,7 +23,6 @@ from coin.mixins import CoinLdapSyncMixin
 from coin import utils
 
 
-
 class MemberManager(UserManager):
     def manageable_by(self, user):
         """" Renvoie la liste des members que l'utilisateur est autorisé à voir
@@ -33,68 +32,100 @@ class MemberManager(UserManager):
             return super(MemberManager, self).all()
         else:
             offers = Offer.objects.manageable_by(user)
-            return super(MemberManager, self).filter(offersubscription__offer__in=offers).distinct()
+            return (
+                super(MemberManager, self)
+                .filter(offersubscription__offer__in=offers)
+                .distinct()
+            )
 
 
 class Member(CoinLdapSyncMixin, AbstractUser):
 
     # USERNAME_FIELD = 'login'
-    REQUIRED_FIELDS = ['first_name', 'last_name', 'email', ]
+    REQUIRED_FIELDS = ["first_name", "last_name", "email"]
 
     MEMBER_TYPE_CHOICES = (
-        ('natural_person', 'Personne physique'),
-        ('legal_entity', 'Personne morale'),
+        ("natural_person", "Personne physique"),
+        ("legal_entity", "Personne morale"),
     )
     MEMBER_STATUS_CHOICES = (
-        ('member', 'Adhérent'),
-        ('not_member', 'Non adhérent'),
-        ('pending', "Demande d'adhésion"),
+        ("member", "Adhérent"),
+        ("not_member", "Non adhérent"),
+        ("pending", "Demande d'adhésion"),
+    )
+
+    status = models.CharField(
+        max_length=50,
+        choices=MEMBER_STATUS_CHOICES,
+        default="member",
+        verbose_name="statut",
+    )
+    type = models.CharField(
+        max_length=20,
+        choices=MEMBER_TYPE_CHOICES,
+        default="natural_person",
+        verbose_name="type",
     )
 
-    status = models.CharField(max_length=50, choices=MEMBER_STATUS_CHOICES,
-                              default='member', verbose_name='statut')
-    type = models.CharField(max_length=20, choices=MEMBER_TYPE_CHOICES,
-                            default='natural_person', verbose_name='type')
-
-    nickname = models.CharField(max_length=64, blank=True,
-                                verbose_name="nom d'usage",
-                                help_text='Pseudonyme, …')
-    organization_name = models.CharField(max_length=200, blank=True,
-                                         verbose_name="nom de l'organisme",
-                                         help_text='Pour une personne morale')
-    home_phone_number = models.CharField(max_length=25, blank=True,
-                                         verbose_name='téléphone fixe')
-    mobile_phone_number = models.CharField(max_length=25, blank=True,
-                                           verbose_name='téléphone mobile')
+    nickname = models.CharField(
+        max_length=64, blank=True, verbose_name="nom d'usage", help_text="Pseudonyme, …"
+    )
+    organization_name = models.CharField(
+        max_length=200,
+        blank=True,
+        verbose_name="nom de l'organisme",
+        help_text="Pour une personne morale",
+    )
+    home_phone_number = models.CharField(
+        max_length=25, blank=True, verbose_name="téléphone fixe"
+    )
+    mobile_phone_number = models.CharField(
+        max_length=25, blank=True, verbose_name="téléphone mobile"
+    )
     # TODO: use a django module that provides an address model? (would
     # support more countries and address types)
-    address = models.TextField(
-        verbose_name='adresse postale', blank=True, null=True)
-    postal_code = models.CharField(max_length=5, blank=True, null=True,
-                                   validators=[RegexValidator(regex=r'^\d{5}$',
-                                                              message='Code postal non valide.')],
-                                   verbose_name='code postal')
-    city = models.CharField(max_length=200, blank=True, null=True,
-                            verbose_name='commune')
-    country = models.CharField(max_length=200, blank=True, null=True,
-                               default='France',
-                               verbose_name='pays')
-    resign_date = models.DateField(null=True, blank=True,
-                                   verbose_name="date de départ de "
-                                   "l'association",
-                                   help_text="En cas de départ prématuré")
-    comments = models.TextField(blank=True, verbose_name='commentaires',
-                                help_text="Commentaires libres (informations"
-                                " spécifiques concernant l'adhésion,"
-                                " raison du départ, etc)")
-    date_last_call_for_membership_fees_email = models.DateTimeField(null=True,
-                        blank=True,
-                        verbose_name="Date du dernier email de relance de cotisation envoyé")
+    address = models.TextField(verbose_name="adresse postale", blank=True, null=True)
+    postal_code = models.CharField(
+        max_length=5,
+        blank=True,
+        null=True,
+        validators=[
+            RegexValidator(regex=r"^\d{5}$", message="Code postal non valide.")
+        ],
+        verbose_name="code postal",
+    )
+    city = models.CharField(
+        max_length=200, blank=True, null=True, verbose_name="commune"
+    )
+    country = models.CharField(
+        max_length=200, blank=True, null=True, default="France", verbose_name="pays"
+    )
+    resign_date = models.DateField(
+        null=True,
+        blank=True,
+        verbose_name="date de départ de " "l'association",
+        help_text="En cas de départ prématuré",
+    )
+    comments = models.TextField(
+        blank=True,
+        verbose_name="commentaires",
+        help_text="Commentaires libres (informations"
+        " spécifiques concernant l'adhésion,"
+        " raison du départ, etc)",
+    )
+    date_last_call_for_membership_fees_email = models.DateTimeField(
+        null=True,
+        blank=True,
+        verbose_name="Date du dernier email de relance de cotisation envoyé",
+    )
     send_membership_fees_email = models.BooleanField(
-        default=True, verbose_name='relance de cotisation',
-        help_text='Précise si l\'utilisateur doit recevoir des mails de relance pour la cotisation. Certains membres n\'ont pas à recevoir de relance (prélèvement automatique, membres d\'honneurs, etc.)')
-    balance = models.DecimalField(max_digits=5, decimal_places=2, default=0,
-                                  verbose_name='account balance')
+        default=True,
+        verbose_name="relance de cotisation",
+        help_text="Précise si l'utilisateur doit recevoir des mails de relance pour la cotisation. Certains membres n'ont pas à recevoir de relance (prélèvement automatique, membres d'honneurs, etc.)",
+    )
+    balance = models.DecimalField(
+        max_digits=5, decimal_places=2, default=0, verbose_name="account balance"
+    )
 
     objects = MemberManager()
 
@@ -109,22 +140,24 @@ class Member(CoinLdapSyncMixin, AbstractUser):
     _password_ldap = None
 
     def clean(self):
-        if self.type == 'legal_entity':
+        if self.type == "legal_entity":
             if not self.organization_name:
-                raise ValidationError("Le nom de l'organisme est obligatoire "
-                                      "pour une personne morale")
-        elif self.type == 'natural_person':
+                raise ValidationError(
+                    "Le nom de l'organisme est obligatoire " "pour une personne morale"
+                )
+        elif self.type == "natural_person":
             if not (self.first_name and self.last_name):
-                raise ValidationError("Le nom et prénom sont obligatoires "
-                                      "pour une personne physique")
+                raise ValidationError(
+                    "Le nom et prénom sont obligatoires " "pour une personne physique"
+                )
 
     def __unicode__(self):
-        if self.type == 'legal_entity':
+        if self.type == "legal_entity":
             return self.organization_name
         elif self.nickname:
             return self.nickname
         else:
-            return self.first_name + ' ' + self.last_name
+            return self.first_name + " " + self.last_name
 
     def get_full_name(self):
         return str(self)
@@ -134,8 +167,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'))
-        return aggregate['end']
+        aggregate = self.membership_fees.aggregate(end=Max("end_date"))
+        return aggregate["end"]
+
     end_date_of_membership.short_description = "Date de fin d'adhésion"
 
     def is_paid_up(self, date=None):
@@ -147,7 +181,7 @@ class Member(CoinLdapSyncMixin, AbstractUser):
         end_date = self.end_date_of_membership()
         if end_date is None:
             return False
-        return (end_date >= date)
+        return end_date >= date
 
     def set_password(self, new_password, *args, **kwargs):
         """
@@ -165,7 +199,8 @@ class Member(CoinLdapSyncMixin, AbstractUser):
         return OfferSubscription.objects.filter(
             Q(member__exact=self.pk),
             Q(subscription_date__lte=date),
-            Q(resign_date__isnull=True) | Q(resign_date__gte=date))
+            Q(resign_date__isnull=True) | Q(resign_date__gte=date),
+        )
 
     def get_inactive_subscriptions(self, date=None):
         """
@@ -175,12 +210,12 @@ class Member(CoinLdapSyncMixin, AbstractUser):
             date = datetime.date.today()
         return OfferSubscription.objects.filter(
             Q(member__exact=self.pk),
-            Q(subscription_date__gt=date) |
-            Q(resign_date__lt=date))
+            Q(subscription_date__gt=date) | Q(resign_date__lt=date),
+        )
 
     def get_ssh_keys(self):
         # Quick & dirty, ensure that keys are unique (otherwise, LDAP complains)
-        return list({k.key for k in self.cryptokey_set.filter(type='RSA')})
+        return list({k.key for k in self.cryptokey_set.filter(type="RSA")})
 
     def sync_ssh_keys(self):
         """
@@ -198,14 +233,21 @@ class Member(CoinLdapSyncMixin, AbstractUser):
         # Do not perform LDAP query if no usefull fields to update are specified
         # in update_fields
         # Ex : at login, last_login field is updated by django auth module.
-        relevant_fields = {'username', 'last_name', 'first_name',
-                           'organization_name', 'email'}
+        relevant_fields = {
+            "username",
+            "last_name",
+            "first_name",
+            "organization_name",
+            "email",
+        }
         if update_fields and relevant_fields.isdisjoint(set(update_fields)):
             return
 
         # Fail if no username specified
-        assert self.username, ('Can\'t sync with LDAP because missing username '
-                               'value for the Member : %s' % self)
+        assert self.username, (
+            "Can't sync with LDAP because missing username "
+            "value for the Member : %s" % self
+        )
 
         # If try to sync a superuser in creation mode
         # Try to retrieve the user in ldap. If exists, switch to update mode
@@ -224,7 +266,7 @@ class Member(CoinLdapSyncMixin, AbstractUser):
         if creation:
             users = LdapUser.objects
             if users.exists():
-                uid_number = users.order_by('-uidNumber')[0].uidNumber + 1
+                uid_number = users.order_by("-uidNumber")[0].uidNumber + 1
             else:
                 uid_number = settings.LDAP_USER_FIRST_UID
             ldap_user = LdapUser()
@@ -232,12 +274,12 @@ class Member(CoinLdapSyncMixin, AbstractUser):
             ldap_user.uid = self.username
             ldap_user.nick_name = self.username
             ldap_user.uidNumber = uid_number
-            ldap_user.homeDirectory = '/home/' + self.username
+            ldap_user.homeDirectory = "/home/" + self.username
 
-        if self.type == 'natural_person':
+        if self.type == "natural_person":
             ldap_user.last_name = self.last_name
             ldap_user.first_name = self.first_name
-        elif self.type == 'legal_entity':
+        elif self.type == "legal_entity":
             ldap_user.last_name = self.organization_name
             ldap_user.first_name = ""
 
@@ -261,8 +303,10 @@ class Member(CoinLdapSyncMixin, AbstractUser):
         """
         Delete member from the LDAP
         """
-        assert self.username, ('Can\'t delete from LDAP because missing '
-                               'username value for the Member : %s' % self)
+        assert self.username, (
+            "Can't delete from LDAP because missing "
+            "username value for the Member : %s" % self
+        )
 
         # Delete user from LDAP
         ldap_user = LdapUser.objects.get(pk=self.username)
@@ -284,14 +328,15 @@ class Member(CoinLdapSyncMixin, AbstractUser):
 
         kwargs = {}
         if isp_info.administrative_email:
-            kwargs['from_email'] = isp_info.administrative_email
+            kwargs["from_email"] = isp_info.administrative_email
 
         utils.send_templated_email(
             to=self.email,
-            subject_template='members/emails/welcome_email_subject.txt',
-            body_template='members/emails/welcome_email.html',
-            context={'member': self, 'branding': isp_info},
-            **kwargs)
+            subject_template="members/emails/welcome_email_subject.txt",
+            body_template="members/emails/welcome_email.html",
+            context={"member": self, "branding": isp_info},
+            **kwargs
+        )
 
     def send_call_for_membership_fees_email(self, auto=False):
         """ Envoie le courriel d'appel à cotisation du membre
@@ -308,22 +353,27 @@ class Member(CoinLdapSyncMixin, AbstractUser):
         kwargs = {}
         # Il peut ne pas y avir d'ISPInfo, ou bien pas d'administrative_email
         if isp_info and isp_info.administrative_email:
-            kwargs['from_email'] = isp_info.administrative_email
+            kwargs["from_email"] = isp_info.administrative_email
 
         # Si le dernier courriel de relance a été envoyé il y a moins de trois
         # semaines, n'envoi pas un nouveau courriel
-        if (not self.date_last_call_for_membership_fees_email
-            or (self.date_last_call_for_membership_fees_email
-                <= timezone.now() + relativedelta(weeks=-3))):
+        if not self.date_last_call_for_membership_fees_email or (
+            self.date_last_call_for_membership_fees_email
+            <= timezone.now() + relativedelta(weeks=-3)
+        ):
             utils.send_templated_email(
                 to=self.email,
-                subject_template='members/emails/call_for_membership_fees_subject.txt',
-                body_template='members/emails/call_for_membership_fees.html',
-                context={'member': self, 'branding': isp_info,
-                         'membership_info_url': settings.MEMBER_MEMBERSHIP_INFO_URL,
-                         'today': datetime.date.today,
-                         'auto_sent': auto},
-                **kwargs)
+                subject_template="members/emails/call_for_membership_fees_subject.txt",
+                body_template="members/emails/call_for_membership_fees.html",
+                context={
+                    "member": self,
+                    "branding": isp_info,
+                    "membership_info_url": settings.MEMBER_MEMBERSHIP_INFO_URL,
+                    "today": datetime.date.today,
+                    "auto_sent": auto,
+                },
+                **kwargs
+            )
             # Sauvegarde en base la date du dernier envoi de mail de relance
             self.date_last_call_for_membership_fees_email = timezone.now()
             self.save()
@@ -331,18 +381,18 @@ class Member(CoinLdapSyncMixin, AbstractUser):
 
         return False
 
-
     class Meta:
-        verbose_name = 'membre'
+        verbose_name = "membre"
+
 
 # Hack to force email to be required by Member model
-Member._meta.get_field('email')._unique = True
-Member._meta.get_field('email').blank = False
-Member._meta.get_field('email').null = False
+Member._meta.get_field("email")._unique = True
+Member._meta.get_field("email").blank = False
+Member._meta.get_field("email").null = False
 
 
 def count_active_members():
-    return Member.objects.filter(status='member').count()
+    return Member.objects.filter(status="member").count()
 
 
 def get_automatic_username(member):
@@ -352,7 +402,7 @@ def get_automatic_username(member):
     """
 
     # S'il s'agit d'une entreprise, utilise son nom:
-    if member.type == 'legal_entity' and member.organization_name:
+    if member.type == "legal_entity" and member.organization_name:
         username = member.organization_name
     # Sinon, si un pseudo est définit, l'utilise
     elif member.nickname:
@@ -360,19 +410,18 @@ def get_automatic_username(member):
     # Sinon, utilise nom et prenom
     elif member.first_name and member.last_name:
         # Première lettre de chaque partie du prénom
-        first_name_letters = ''.join(
-            [c[0] for c in member.first_name.split('-')]
-        )
+        first_name_letters = "".join([c[0] for c in member.first_name.split("-")])
         # Concaténer avec nom de famille
-        username = ('%s%s' % (first_name_letters, member.last_name))
+        username = "%s%s" % (first_name_letters, member.last_name)
     else:
-        raise Exception('Il n\'y a pas sufissement d\'informations pour déterminer un login automatiquement')
+        raise Exception(
+            "Il n'y a pas sufissement d'informations pour déterminer un login automatiquement"
+        )
 
     # Remplacer ou enlever les caractères non ascii
-    username = unicodedata.normalize('NFD', username)\
-        .encode('ascii', 'ignore')
+    username = unicodedata.normalize("NFD", username).encode("ascii", "ignore")
     # Enlever ponctuation (sauf _-.) et espace
-    punctuation = ('!"#$%&\'()*+,/:;<=>?@[\\]^`{|}~ ').encode('ascii')
+    punctuation = ("!\"#$%&'()*+,/:;<=>?@[\\]^`{|}~ ").encode("ascii")
     username = username.translate(None, punctuation)
     # En minuscule
     username = username.lower()
@@ -386,7 +435,7 @@ def get_automatic_username(member):
     # Tant qu'un membre est trouvé, incrémente un entier à la fin
     while member:
         if len(base_username) >= 30:
-            username = base_username[30 - len(str(incr)):]
+            username = base_username[30 - len(str(incr)) :]
         else:
             username = base_username
         username = username + str(incr)
@@ -398,12 +447,11 @@ def get_automatic_username(member):
 
 class CryptoKey(CoinLdapSyncMixin, models.Model):
 
-    KEY_TYPE_CHOICES = (('RSA', 'RSA'), ('GPG', 'GPG'))
+    KEY_TYPE_CHOICES = (("RSA", "RSA"), ("GPG", "GPG"))
 
-    type = models.CharField(max_length=3, choices=KEY_TYPE_CHOICES,
-                            verbose_name='type')
-    key = models.TextField(verbose_name='clé')
-    member = models.ForeignKey('Member', verbose_name='membre')
+    type = models.CharField(max_length=3, choices=KEY_TYPE_CHOICES, verbose_name="type")
+    key = models.TextField(verbose_name="clé")
+    member = models.ForeignKey("Member", verbose_name="membre")
 
     def sync_to_ldap(self, creation, *args, **kwargs):
         """Simply tell the member object to resync all its SSH keys to LDAP"""
@@ -413,80 +461,99 @@ class CryptoKey(CoinLdapSyncMixin, models.Model):
         self.member.sync_ssh_keys()
 
     def __unicode__(self):
-        return 'Clé %s de %s' % (self.type, self.member)
+        return "Clé %s de %s" % (self.type, self.member)
 
     class Meta:
-        verbose_name = 'clé'
+        verbose_name = "clé"
 
 
 class MembershipFee(models.Model):
     PAYMENT_METHOD_CHOICES = (
-        ('cash', 'Espèces'),
-        ('check', 'Chèque'),
-        ('transfer', 'Virement'),
-        ('other', 'Autre')
+        ("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(
+    member = models.ForeignKey(
+        "Member", related_name="membership_fees", verbose_name="membre"
+    )
+    amount = models.DecimalField(
         null=False,
-        blank=False,
-        verbose_name='date de début de cotisation')
+        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')
+        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)
+        return "%s - %s - %i€" % (self.member, self.start_date, self.amount)
 
     class Meta:
-        verbose_name = 'cotisation'
+        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
-    object_classes = [b'inetOrgPerson', b'organizationalPerson', b'person',
-                      b'top', b'posixAccount', b'ldapPublicKey']
-
-    uid = CharField(db_column=b'uid', unique=True, max_length=255)
-    nick_name = CharField(db_column=b'cn', unique=True, primary_key=True,
-                          max_length=255)
-    first_name = CharField(db_column=b'givenName', max_length=255)
-    last_name = CharField(db_column=b'sn', max_length=255)
-    display_name = CharField(db_column=b'displayName', max_length=255,
-                             blank=True)
-    password = CharField(db_column=b'userPassword', max_length=255)
-    uidNumber = IntegerField(db_column=b'uidNumber', unique=True)
-    gidNumber = IntegerField(db_column=b'gidNumber', default=2000)
+    object_classes = [
+        b"inetOrgPerson",
+        b"organizationalPerson",
+        b"person",
+        b"top",
+        b"posixAccount",
+        b"ldapPublicKey",
+    ]
+
+    uid = CharField(db_column=b"uid", unique=True, max_length=255)
+    nick_name = CharField(
+        db_column=b"cn", unique=True, primary_key=True, max_length=255
+    )
+    first_name = CharField(db_column=b"givenName", max_length=255)
+    last_name = CharField(db_column=b"sn", max_length=255)
+    display_name = CharField(db_column=b"displayName", max_length=255, blank=True)
+    password = CharField(db_column=b"userPassword", max_length=255)
+    uidNumber = IntegerField(db_column=b"uidNumber", unique=True)
+    gidNumber = IntegerField(db_column=b"gidNumber", default=2000)
     # Used by Sympa for logging in.
-    mail = CharField(db_column=b'mail', max_length=255, blank=True,
-                     unique=True)
-    homeDirectory = CharField(db_column=b'homeDirectory', max_length=255,
-                              default='/tmp')
-    loginShell = CharField(db_column=b'loginShell', max_length=255,
-                              default='/bin/bash')
-    sshPublicKey = ListField(db_column=b'sshPublicKey', default=[])
+    mail = CharField(db_column=b"mail", max_length=255, blank=True, unique=True)
+    homeDirectory = CharField(
+        db_column=b"homeDirectory", max_length=255, default="/tmp"
+    )
+    loginShell = CharField(db_column=b"loginShell", max_length=255, default="/bin/bash")
+    sshPublicKey = ListField(db_column=b"sshPublicKey", default=[])
 
     def __unicode__(self):
         return self.display_name
@@ -511,7 +578,6 @@ 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,15 +595,16 @@ def define_display_name(sender, instance, **kwargs):
     concaténation de first_name et last_name
     """
     if not instance.display_name:
-        instance.display_name = '%s %s' % (instance.first_name,
-                                           instance.last_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.")
+        "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):
@@ -569,10 +636,14 @@ class RowLevelPermission(Permission):
         return codename
 
     class Meta:
-        verbose_name = 'permission fine'
-        verbose_name_plural = 'permissions fines'
+        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"
+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"

+ 154 - 129
coin/members/tests.py

@@ -30,16 +30,14 @@ class LDAPMemberTests(TestCase):
         Cela concerne le nom et le prénom
         """
 
-        #~ Créé un membre
-        first_name = 'Gérard'
-        last_name = 'Majax'
+        # ~ Créé un membre
+        first_name = "Gérard"
+        last_name = "Majax"
         username = MemberTestsUtils.get_random_username()
-        member = Member(first_name=first_name,
-                        last_name=last_name,
-                        username=username)
+        member = Member(first_name=first_name, last_name=last_name, username=username)
         member.save()
 
-        #~ Récupère l'utilisateur LDAP et fait les tests
+        # ~ Récupère l'utilisateur LDAP et fait les tests
         ldap_user = LdapUser.objects.get(pk=username)
 
         self.assertEqual(ldap_user.first_name, first_name)
@@ -48,28 +46,29 @@ class LDAPMemberTests(TestCase):
 
         member.delete()
 
-    def test_when_modifiying_member_corresponding_ldap_user_is_also_modified_with_same_data(self):
+    def test_when_modifiying_member_corresponding_ldap_user_is_also_modified_with_same_data(
+        self
+    ):
         """
         Test que lorsque l'on modifie un membre, l'utilisateur LDAP
         correspondant est bien modifié
         Cela concerne le no met le prénom
         """
-        #~ Créé un membre
-        first_name = 'Ronald'
-        last_name = 'Mac Donald'
+        # ~ Créé un membre
+        first_name = "Ronald"
+        last_name = "Mac Donald"
         username = MemberTestsUtils.get_random_username()
-        member = Member(first_name=first_name,
-                        last_name=last_name, username=username)
+        member = Member(first_name=first_name, last_name=last_name, username=username)
         member.save()
 
-        #~  Le modifie
-        new_first_name = 'José'
-        new_last_name = 'Bové'
+        # ~  Le modifie
+        new_first_name = "José"
+        new_last_name = "Bové"
         member.first_name = new_first_name
         member.last_name = new_last_name
         member.save()
 
-        #~ Récupère l'utilisateur LDAP et fait les tests
+        # ~ Récupère l'utilisateur LDAP et fait les tests
         ldap_user = LdapUser.objects.get(pk=username)
 
         self.assertEqual(ldap_user.first_name, new_first_name)
@@ -110,36 +109,37 @@ class LDAPMemberTests(TestCase):
         username = MemberTestsUtils.get_random_username()
         password = "1234"
 
-        #~ Créé un nouveau membre
-        member = Member(first_name='Passe-partout',
-                        last_name='Du fort Boyard', username=username)
+        # ~ Créé un nouveau membre
+        member = Member(
+            first_name="Passe-partout", last_name="Du fort Boyard", username=username
+        )
         member.save()
 
-        #~ Récupère l'utilisateur LDAP
+        # ~ Récupère l'utilisateur LDAP
         ldap_user = LdapUser.objects.get(pk=username)
 
-        #~ Change son mot de passe
+        # ~ Change son mot de passe
         member.set_password(password)
         member.save()
 
-        #~ Test l'authentification django
+        # ~ Test l'authentification django
         c = Client()
         self.assertEqual(c.login(username=username, password=password), True)
 
         # Test l'authentification ldap
         import ldap
-        ldap_conn_settings = db.connections['ldap'].settings_dict
-        l = ldap.initialize(ldap_conn_settings['NAME'])
-        options = ldap_conn_settings.get('CONNECTION_OPTIONS', {})
+
+        ldap_conn_settings = db.connections["ldap"].settings_dict
+        l = ldap.initialize(ldap_conn_settings["NAME"])
+        options = ldap_conn_settings.get("CONNECTION_OPTIONS", {})
         for opt, value in options.items():
             l.set_option(opt, value)
 
-        if ldap_conn_settings.get('TLS', False):
+        if ldap_conn_settings.get("TLS", False):
             l.start_tls_s()
 
         # Raise "Invalid credentials" exception if auth fail
-        l.simple_bind_s(ldap_conn_settings['USER'],
-                        ldap_conn_settings['PASSWORD'])
+        l.simple_bind_s(ldap_conn_settings["USER"], ldap_conn_settings["PASSWORD"])
 
         l.unbind_s()
 
@@ -150,22 +150,19 @@ class LDAPMemberTests(TestCase):
         Lors de la création d'un membre, le champ "display_name" du LDAP est
         prenom + nom
         """
-        first_name = 'Gérard'
-        last_name = 'Majax'
+        first_name = "Gérard"
+        last_name = "Majax"
         username = MemberTestsUtils.get_random_username()
-        member = Member(first_name=first_name,
-                        last_name=last_name, username=username)
+        member = Member(first_name=first_name, last_name=last_name, username=username)
         member.save()
 
-        #~ Récupère l'utilisateur LDAP
+        # ~ Récupère l'utilisateur LDAP
         ldap_user = LdapUser.objects.get(pk=username)
 
-        self.assertEqual(ldap_user.display_name, '%s %s' %
-                         (first_name, last_name))
+        self.assertEqual(ldap_user.display_name, "%s %s" % (first_name, last_name))
 
         member.delete()
 
-
     def test_when_saving_member_and_ldap_fail_dont_save(self):
         """
         Test que lors de la sauvegarde d'un membre et que la sauvegarde en LDAP
@@ -174,18 +171,17 @@ class LDAPMemberTests(TestCase):
 
         # Fait échouer le LDAP en définissant un mauvais mot de passe
         for dbconnection in db.connections.all():
-            if (type(dbconnection) is
-                    ldapdb.backends.ldap.base.DatabaseWrapper):
+            if type(dbconnection) is ldapdb.backends.ldap.base.DatabaseWrapper:
                 dbconnection.settings_dict[
-                    'PREVIOUSPASSWORD'] = dbconnection.settings_dict['PASSWORD']
-                dbconnection.settings_dict['PASSWORD'] = 'wrong password test'
+                    "PREVIOUSPASSWORD"
+                ] = dbconnection.settings_dict["PASSWORD"]
+                dbconnection.settings_dict["PASSWORD"] = "wrong password test"
 
         # Créé un membre
-        first_name = 'Du'
-        last_name = 'Pont'
+        first_name = "Du"
+        last_name = "Pont"
         username = MemberTestsUtils.get_random_username()
-        member = Member(first_name=first_name,
-                        last_name=last_name, username=username)
+        member = Member(first_name=first_name, last_name=last_name, username=username)
 
         # Le sauvegarde en base de donnée
         # Le save devrait renvoyer une exception parceque le LDAP échoue
@@ -197,10 +193,10 @@ class LDAPMemberTests(TestCase):
 
         # Restaure le mot de passe pour les tests suivants
         for dbconnection in db.connections.all():
-            if (type(dbconnection) is
-                    ldapdb.backends.ldap.base.DatabaseWrapper):
-                dbconnection.settings_dict[
-                    'PASSWORD'] = dbconnection.settings_dict['PREVIOUSPASSWORD']
+            if type(dbconnection) is ldapdb.backends.ldap.base.DatabaseWrapper:
+                dbconnection.settings_dict["PASSWORD"] = dbconnection.settings_dict[
+                    "PREVIOUSPASSWORD"
+                ]
 
     # def test_when_user_login_member_user_field_is_updated(self):
     #     """
@@ -239,11 +235,11 @@ class MemberTests(TestCase):
         premières lettres du prénom + nom le tout en minuscule,
         sans caractères accentués et sans espaces.
         """
-        random = os.urandom(4).encode('hex')
-        first_name = 'Gérard-Étienne'
-        last_name = 'Majax de la Boétie!B' + random
+        random = os.urandom(4).encode("hex")
+        first_name = "Gérard-Étienne"
+        last_name = "Majax de la Boétie!B" + random
 
-        control = 'gemajaxdelaboetieb' + random
+        control = "gemajaxdelaboetieb" + random
         control = control[:30]
 
         member = Member(first_name=first_name, last_name=last_name)
@@ -258,19 +254,25 @@ class MemberTests(TestCase):
         Lors de la création d'un membre, test si le username existe déjà,
         renvoi avec un incrément à la fin
         """
-        random = os.urandom(4).encode('hex')
+        random = os.urandom(4).encode("hex")
 
-        member1 = Member(first_name='Hervé', last_name='DUPOND' + random, email='hdupond@coin.org')
+        member1 = Member(
+            first_name="Hervé", last_name="DUPOND" + random, email="hdupond@coin.org"
+        )
         member1.save()
-        self.assertEqual(member1.username, 'hdupond' + random)
+        self.assertEqual(member1.username, "hdupond" + random)
 
-        member2 = Member(first_name='Henri', last_name='DUPOND' + random, email='hdupond2@coin.org')
+        member2 = Member(
+            first_name="Henri", last_name="DUPOND" + random, email="hdupond2@coin.org"
+        )
         member2.save()
-        self.assertEqual(member2.username, 'hdupond' + random + '2')
+        self.assertEqual(member2.username, "hdupond" + random + "2")
 
-        member3 = Member(first_name='Hector', last_name='DUPOND' + random, email='hdupond3@coin.org')
+        member3 = Member(
+            first_name="Hector", last_name="DUPOND" + random, email="hdupond3@coin.org"
+        )
         member3.save()
-        self.assertEqual(member3.username, 'hdupond' + random + '3')
+        self.assertEqual(member3.username, "hdupond" + random + "3")
 
         member1.delete()
         member2.delete()
@@ -281,20 +283,29 @@ class MemberTests(TestCase):
         Lors de la créatio d'une entreprise, son nom doit être utilisée lors de
         la détermination automatique du username
         """
-        random = os.urandom(4).encode('hex')
-        member = Member(type='legal_entity', organization_name='ILLYSE' + random, email='illyse@coin.org')
+        random = os.urandom(4).encode("hex")
+        member = Member(
+            type="legal_entity",
+            organization_name="ILLYSE" + random,
+            email="illyse@coin.org",
+        )
         member.save()
-        self.assertEqual(member.username, 'illyse' + random)
+        self.assertEqual(member.username, "illyse" + random)
         member.delete()
 
     def test_when_creating_member_with_nickname_it_is_used_for_username(self):
         """
         Lors de la création d'une personne, qui a un pseudo, celui-ci est utilisé en priorité
         """
-        random = os.urandom(4).encode('hex')
-        member = Member(first_name='Richard', last_name='Stallman', nickname='rms' + random, email='illyse@coin.org')
+        random = os.urandom(4).encode("hex")
+        member = Member(
+            first_name="Richard",
+            last_name="Stallman",
+            nickname="rms" + random,
+            email="illyse@coin.org",
+        )
         member.save()
-        self.assertEqual(member.username, 'rms' + random)
+        self.assertEqual(member.username, "rms" + random)
 
         member.delete()
 
@@ -303,20 +314,19 @@ class MemberTests(TestCase):
         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'
+        first_name = "Tin"
+        last_name = "Tin"
         username = MemberTestsUtils.get_random_username()
-        member = Member(first_name=first_name,
-                        last_name=last_name, username=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 = MembershipFee(
+            member=member, amount=20, start_date=start_date, end_date=end_date
+        )
         membershipfee.save()
 
         self.assertEqual(member.end_date_of_membership(), end_date)
@@ -326,11 +336,10 @@ class MemberTests(TestCase):
         Test l'état "a jour de cotisation" d'un adhérent.
         """
         # Créé un membre
-        first_name = 'Capitain'
-        last_name = 'Haddock'
+        first_name = "Capitain"
+        last_name = "Haddock"
         username = MemberTestsUtils.get_random_username()
-        member = Member(first_name=first_name,
-                        last_name=last_name, username=username)
+        member = Member(first_name=first_name, last_name=last_name, username=username)
         member.save()
 
         start_date = date.today()
@@ -340,20 +349,24 @@ class MemberTests(TestCase):
         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 = 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 = 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
@@ -364,7 +377,7 @@ class MemberTests(TestCase):
         Test qu'un membre ne peut pas être créé sans "noms"
         (prenom, nom) ou pseudo ou nom d'organization
         """
-        member = Member(username='blop')
+        member = Member(username="blop")
         with self.assertRaises(Exception):
             member.full_clean()
             member.save()
@@ -374,23 +387,26 @@ class MemberTests(TestCase):
             member.save()
 
 
-
 class MemberAdminTests(TestCase):
-
     def setUp(self):
-        #~ Client web
+        # ~ Client web
         self.client = Client()
-        #~ Créé un superuser
-        self.admin_user_password = '1234'
+        # ~ Créé un superuser
+        self.admin_user_password = "1234"
         self.admin_user = Member.objects.create_superuser(
-            username='test_admin_user',
-            first_name='test',
-            last_name='Admin user',
-            email='i@mail.com',
-            password=self.admin_user_password)
-        #~ Connection
-        self.assertEqual(self.client.login(
-            username=self.admin_user.username, password=self.admin_user_password), True)
+            username="test_admin_user",
+            first_name="test",
+            last_name="Admin user",
+            email="i@mail.com",
+            password=self.admin_user_password,
+        )
+        # ~ Connection
+        self.assertEqual(
+            self.client.login(
+                username=self.admin_user.username, password=self.admin_user_password
+            ),
+            True,
+        )
 
     def tearDown(self):
         # Supprime le superuser
@@ -401,32 +417,28 @@ class MemberAdminTests(TestCase):
         Vérifie que dans l'admin Django, le champ username n'est pad modifiable
         sur une fiche existante
         """
-        #~ Créé un membre
-        first_name = 'Gérard'
-        last_name = 'Majax'
+        # ~ Créé un membre
+        first_name = "Gérard"
+        last_name = "Majax"
         username = MemberTestsUtils.get_random_username()
-        member = Member(first_name=first_name,
-                        last_name=last_name, username=username)
+        member = Member(first_name=first_name, last_name=last_name, username=username)
         member.save()
 
-        edit_page = self.client.get('/admin/members/member/%i/' % member.id)
-        self.assertNotContains(edit_page,
-                               '''<input id="id_username" />''',
-                               html=True)
+        edit_page = self.client.get("/admin/members/member/%i/" % member.id)
+        self.assertNotContains(edit_page, """<input id="id_username" />""", html=True)
 
         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 = Member(
+            first_name="Richard", last_name="Stallman", username=self.username
+        )
         self.member.save()
 
-
     def tearDown(self):
         # Supprime le membre
         self.member.delete()
@@ -434,24 +446,30 @@ class MemberTestCallForMembershipCommand(TestCase):
 
     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 = 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 = 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):
+    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())
+        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
@@ -459,7 +477,9 @@ class MemberTestCallForMembershipCommand(TestCase):
             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):
+    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
@@ -477,17 +497,23 @@ class MemberTestCallForMembershipCommand(TestCase):
 
     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))
+        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))
+        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))
+        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
@@ -495,25 +521,24 @@ class MemberTestCallForMembershipCommand(TestCase):
 
 
 class MemberTestsUtils(object):
-
     @staticmethod
     def get_random_username():
         """
         Renvoi une clé aléatoire pour un utilisateur LDAP
         """
-        return 'coin_test_' + os.urandom(8).encode('hex')
+        return "coin_test_" + os.urandom(8).encode("hex")
 
 
 class TestValidators(TestCase):
     def test_valid_chatroom(self):
-        chatroom_url_validator('irc://irc.example.com/#chan')
+        chatroom_url_validator("irc://irc.example.com/#chan")
         with self.assertRaises(ValidationError):
-            chatroom_url_validator('http://#faimaison@irc.geeknode.org')
+            chatroom_url_validator("http://#faimaison@irc.geeknode.org")
 
 
 class MembershipFeeTests(TestCase):
     def test_mandatory_start_date(self):
-        member = Member(first_name='foo', last_name='foo', password='foo', email='foo')
+        member = Member(first_name="foo", last_name="foo", password="foo", email="foo")
         member.save()
 
         # If there is no start_date clean_fields() should raise an

+ 63 - 42
coin/members/urls.py

@@ -8,47 +8,68 @@ from coin.members.models import Member
 
 
 urlpatterns = patterns(
-    '',
-    url(r'^$', views.index, name='index'),
-    url(r'^login/$', 'django.contrib.auth.views.login',
-        {'template_name': 'members/registration/login.html'},
-        name='login'),
-    url(r'^logout/$', 'django.contrib.auth.views.logout_then_login',
-        name='logout'),
-
-    url(r'^password_change/$', 'django.contrib.auth.views.password_change',
-        {'post_change_redirect': 'members:password_change_done',
-         'template_name': 'members/registration/password_change_form.html'},
-        name='password_change'),
-    url(r'^password_change_done/$', 'django.contrib.auth.views.password_change_done',
-        {'template_name': 'members/registration/password_change_done.html'},
-        name='password_change_done'),
-
-    url(r'^password_reset/$', 'django.contrib.auth.views.password_reset',
-        {'post_reset_redirect': 'members:password_reset_done',
-         'template_name': 'members/registration/password_reset_form.html',
-         'email_template_name': 'members/registration/password_reset_email.html',
-         'subject_template_name': 'members/registration/password_reset_subject.txt'},
-        name='password_reset'),
-    url(r'^password_reset/done/$', 'django.contrib.auth.views.password_reset_done',
-        {'template_name': 'members/registration/password_reset_done.html',
-         'current_app': 'members'},
-        name='password_reset_done'),
-    url(r'^password_reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', 'django.contrib.auth.views.password_reset_confirm',
-        {'post_reset_redirect': 'members:password_reset_complete',
-         'template_name': 'members/registration/password_reset_confirm.html'},
-        name='password_reset_confirm'),
-    url(r'^password_reset/complete/$', 'django.contrib.auth.views.password_reset_complete',
-        {'template_name': 'members/registration/password_reset_complete.html'},
-        name='password_reset_complete'),
-
-
-    url(r'^detail/$', views.detail,
-        name='detail'),
-
-    url(r'^subscriptions/', views.subscriptions, name='subscriptions'),
+    "",
+    url(r"^$", views.index, name="index"),
+    url(
+        r"^login/$",
+        "django.contrib.auth.views.login",
+        {"template_name": "members/registration/login.html"},
+        name="login",
+    ),
+    url(r"^logout/$", "django.contrib.auth.views.logout_then_login", name="logout"),
+    url(
+        r"^password_change/$",
+        "django.contrib.auth.views.password_change",
+        {
+            "post_change_redirect": "members:password_change_done",
+            "template_name": "members/registration/password_change_form.html",
+        },
+        name="password_change",
+    ),
+    url(
+        r"^password_change_done/$",
+        "django.contrib.auth.views.password_change_done",
+        {"template_name": "members/registration/password_change_done.html"},
+        name="password_change_done",
+    ),
+    url(
+        r"^password_reset/$",
+        "django.contrib.auth.views.password_reset",
+        {
+            "post_reset_redirect": "members:password_reset_done",
+            "template_name": "members/registration/password_reset_form.html",
+            "email_template_name": "members/registration/password_reset_email.html",
+            "subject_template_name": "members/registration/password_reset_subject.txt",
+        },
+        name="password_reset",
+    ),
+    url(
+        r"^password_reset/done/$",
+        "django.contrib.auth.views.password_reset_done",
+        {
+            "template_name": "members/registration/password_reset_done.html",
+            "current_app": "members",
+        },
+        name="password_reset_done",
+    ),
+    url(
+        r"^password_reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$",
+        "django.contrib.auth.views.password_reset_confirm",
+        {
+            "post_reset_redirect": "members:password_reset_complete",
+            "template_name": "members/registration/password_reset_confirm.html",
+        },
+        name="password_reset_confirm",
+    ),
+    url(
+        r"^password_reset/complete/$",
+        "django.contrib.auth.views.password_reset_complete",
+        {"template_name": "members/registration/password_reset_complete.html"},
+        name="password_reset_complete",
+    ),
+    url(r"^detail/$", views.detail, name="detail"),
+    url(r"^subscriptions/", views.subscriptions, name="subscriptions"),
     # url(r'^subscription/(?P<id>\d+)', views.subscriptions, name = 'subscription'),
-
-    url(r'^invoices/', views.invoices, name='invoices'),
-    url(r'^contact/', views.contact, name='contact'),
+    url(r"^invoices/", views.invoices, name="invoices"),
+    url(r"^contact/", views.contact, name="contact"),
 )

+ 34 - 27
coin/members/views.py

@@ -8,21 +8,22 @@ from django.http import Http404
 from django.conf import settings
 from forms import PersonMemberChangeForm, OrganizationMemberChangeForm
 
+
 @login_required
 def index(request):
-    has_isp_feed = 'isp' in [k for k, _, _ in settings.FEEDS]
-    return render_to_response('members/index.html',
-                              {'has_isp_feed': has_isp_feed},
-                              context_instance=RequestContext(request))
+    has_isp_feed = "isp" in [k for k, _, _ in settings.FEEDS]
+    return render_to_response(
+        "members/index.html",
+        {"has_isp_feed": has_isp_feed},
+        context_instance=RequestContext(request),
+    )
 
 
 @login_required
 def detail(request):
 
     membership_info_url = settings.MEMBER_MEMBERSHIP_INFO_URL
-    context={
-        'membership_info_url': membership_info_url,
-    }
+    context = {"membership_info_url": membership_info_url}
 
     if settings.MEMBER_CAN_EDIT_PROFILE:
         if request.user.type == "natural_person":
@@ -31,15 +32,15 @@ def detail(request):
             form_cls = OrganizationMemberChangeForm
 
         if request.method == "POST":
-            form = form_cls(data = request.POST, instance = request.user)
+            form = form_cls(data=request.POST, instance=request.user)
             if form.is_valid():
                 form.save()
         else:
-            form = form_cls(instance = request.user)
+            form = form_cls(instance=request.user)
 
-        context['form'] = form
+        context["form"] = form
 
-    return render(request, 'members/detail.html', context)
+    return render(request, "members/detail.html", context)
 
 
 @login_required
@@ -47,27 +48,33 @@ def subscriptions(request):
     subscriptions = request.user.get_active_subscriptions()
     old_subscriptions = request.user.get_inactive_subscriptions()
 
-    return render_to_response('members/subscriptions.html',
-                              {'subscriptions': subscriptions,
-                               'old_subscriptions': old_subscriptions},
-                              context_instance=RequestContext(request))
+    return render_to_response(
+        "members/subscriptions.html",
+        {"subscriptions": subscriptions, "old_subscriptions": old_subscriptions},
+        context_instance=RequestContext(request),
+    )
 
 
 @login_required
 def invoices(request):
-    balance  = request.user.balance
-    invoices = request.user.invoices.filter(validated=True).order_by('-date')
-    payments = request.user.payments.filter().order_by('-date')
-
-    return render_to_response('members/invoices.html',
-                              {'balance' : balance, 
-                               'handle_balance' : settings.HANDLE_BALANCE, 
-                               'invoices': invoices, 
-                               'payments': payments},
-                              context_instance=RequestContext(request))
+    balance = request.user.balance
+    invoices = request.user.invoices.filter(validated=True).order_by("-date")
+    payments = request.user.payments.filter().order_by("-date")
+
+    return render_to_response(
+        "members/invoices.html",
+        {
+            "balance": balance,
+            "handle_balance": settings.HANDLE_BALANCE,
+            "invoices": invoices,
+            "payments": payments,
+        },
+        context_instance=RequestContext(request),
+    )
 
 
 @login_required
 def contact(request):
-    return render_to_response('members/contact.html',
-                              context_instance=RequestContext(request))
+    return render_to_response(
+        "members/contact.html", context_instance=RequestContext(request)
+    )

+ 10 - 9
coin/mixins.py

@@ -17,21 +17,23 @@ class CoinLdapSyncMixin(object):
     """
 
     def sync_to_ldap(self, creation, *args, **kwargs):
-        raise NotImplementedError('Using CoinLdapSyncModel require '
-                                  'sync_to_ldap method being implemented')
+        raise NotImplementedError(
+            "Using CoinLdapSyncModel require " "sync_to_ldap method being implemented"
+        )
 
     def delete_from_ldap(self, *args, **kwargs):
-        raise NotImplementedError('Using CoinLdapSyncModel require '
-                                  'delete_from_ldap method being implemented')
+        raise NotImplementedError(
+            "Using CoinLdapSyncModel require "
+            "delete_from_ldap method being implemented"
+        )
 
     @transaction.atomic
     def save(self, *args, **kwargs):
         # Détermine si on est dans une création ou une mise à jour
-        creation = (self.pk == None)
+        creation = self.pk == None
 
         # Récupère les champs mis à jour si cela est précisé
-        update_fields = kwargs[
-            'update_fields'] if 'update_fields' in kwargs else None
+        update_fields = kwargs["update_fields"] if "update_fields" in kwargs else None
 
         # Sauvegarde en base de donnée (mais sans commit, cf decorator)
         super(CoinLdapSyncMixin, self).save(*args, **kwargs)
@@ -41,8 +43,7 @@ class CoinLdapSyncMixin(object):
         # commit
         if settings.LDAP_ACTIVATE:
             try:
-                self.sync_to_ldap(
-                    creation=creation, update_fields=update_fields)
+                self.sync_to_ldap(creation=creation, update_fields=update_fields)
             except:
                 raise
 

+ 55 - 28
coin/offers/admin.py

@@ -7,19 +7,26 @@ from polymorphic.admin import PolymorphicChildModelAdmin
 
 from coin.members.models import Member
 from coin.offers.models import Offer, OfferSubscription
-from coin.offers.offersubscription_filter import\
-            OfferSubscriptionTerminationFilter,\
-            OfferSubscriptionCommitmentFilter
+from coin.offers.offersubscription_filter import (
+    OfferSubscriptionTerminationFilter,
+    OfferSubscriptionCommitmentFilter,
+)
 from coin.offers.forms import OfferAdminForm
 import autocomplete_light
 
 
 class OfferAdmin(admin.ModelAdmin):
-    list_display = ('get_configuration_type_display', 'name', 'reference', 'billing_period', 'period_fees',
-                    'initial_fees')
-    list_display_links = ('name',)
-    list_filter = ('configuration_type',)
-    search_fields = ['name']
+    list_display = (
+        "get_configuration_type_display",
+        "name",
+        "reference",
+        "billing_period",
+        "period_fees",
+        "initial_fees",
+    )
+    list_display_links = ("name",)
+    list_filter = ("configuration_type",)
+    search_fields = ["name"]
     form = OfferAdminForm
 
     # def get_readonly_fields(self, request, obj=None):
@@ -30,27 +37,42 @@ class OfferAdmin(admin.ModelAdmin):
 
 
 class OfferSubscriptionAdmin(admin.ModelAdmin):
-    list_display = ('get_subscription_reference', 'member', 'offer',
-                    'subscription_date', 'commitment', 'resign_date')
-    list_display_links = ('member','offer')
-    list_filter = ( OfferSubscriptionTerminationFilter,
-                    OfferSubscriptionCommitmentFilter,
-                    'offer', 'member')
-    search_fields = ['member__first_name', 'member__last_name', 'member__email',
-                     'member__nickname']
-    
+    list_display = (
+        "get_subscription_reference",
+        "member",
+        "offer",
+        "subscription_date",
+        "commitment",
+        "resign_date",
+    )
+    list_display_links = ("member", "offer")
+    list_filter = (
+        OfferSubscriptionTerminationFilter,
+        OfferSubscriptionCommitmentFilter,
+        "offer",
+        "member",
+    )
+    search_fields = [
+        "member__first_name",
+        "member__last_name",
+        "member__email",
+        "member__nickname",
+    ]
+
     fields = (
-                'member',
-                'offer',
-                'subscription_date',
-                'commitment',
-                'resign_date',
-                'comments'
-             )
+        "member",
+        "offer",
+        "subscription_date",
+        "commitment",
+        "resign_date",
+        "comments",
+    )
     # 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__')
+            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
@@ -59,8 +81,12 @@ class OfferSubscriptionAdmin(admin.ModelAdmin):
             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)
+                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):
@@ -78,9 +104,10 @@ class OfferSubscriptionAdmin(admin.ModelAdmin):
         """
         if obj is not None:
             for item in PolymorphicChildModelAdmin.__subclasses__():
-                if (item.base_model.__name__ == obj.offer.configuration_type):
+                if item.base_model.__name__ == obj.offer.configuration_type:
                     return [item.inline(self.model, self.admin_site)]
         return []
 
+
 admin.site.register(Offer, OfferAdmin)
 admin.site.register(OfferSubscription, OfferSubscriptionAdmin)

+ 5 - 2
coin/offers/forms.py

@@ -9,6 +9,9 @@ class OfferAdminForm(ModelForm):
     class Meta:
         model = Offer
         widgets = {
-            'configuration_type': Select(choices=(('','---------'),) + Configuration.get_configurations_choices_list())
+            "configuration_type": Select(
+                choices=(("", "---------"),)
+                + Configuration.get_configurations_choices_list()
+            )
         }
-        exclude = ('', )
+        exclude = ("",)

+ 28 - 11
coin/offers/management/commands/offer_subscriptions_count.py

@@ -9,36 +9,53 @@ from django.db.models import Q, Count
 
 from coin.offers.models import Offer, OfferSubscription
 
+# This file could not be formatted by Black.
+
 
 BOLD_START = '\033[1m'
 BOLD_END = '\033[0m'
 
+
 class Command(BaseCommand):
     option_list = BaseCommand.option_list + (
-        make_option('--date', action='store', dest='date',
-                default=datetime.date.today(), help='Specifies the date to use. Format is YYYY-MM-DD. Default is "today".'),
+        make_option(
+            "--date",
+            action="store", dest="date",
+            default=datetime.date.today(),
+            help='Specifies the date to use. Format is YYYY-MM-DD. Default is "today".'
+        ),
     )
     help = "Return subscription count for each offer type"
 
     def handle(self, *args, **options):
         # Get date option
-        date = options.get('date')
+        date = options.get("date")
 
         # Validate date type
         if type(date) is not datetime.date:
             try:
-                datetime.datetime.strptime(date, '%Y-%m-%d')
+                datetime.datetime.strptime(date, "%Y-%m-%d")
             except ValueError, TypeError:
                 raise CommandError("Incorrect date format, should be YYYY-MM-DD")
 
         # Count offer subscription
-        offers = Offer.objects\
-                      .filter(Q(offersubscription__subscription_date__lte=date) & (Q(offersubscription__resign_date__gt=date) | Q(offersubscription__resign_date__isnull=True)))\
-                      .annotate(num_subscribtions=Count('offersubscription'))\
-                      .order_by('name')
+        offers = (Offer.objects
+            .filter(
+                Q(offersubscription__subscription_date__lte=date) &
+                (
+                    Q(offersubscription__resign_date__gt=date) |
+                    Q(offersubscription__resign_date__isnull=True)
+                )
+            )
+            .annotate(num_subscribtions=Count("offersubscription"))
+            .order_by("name")
+        )
 
         # Print count by offer type
         for offer in offers:
-            self.stdout.write("{offer} offer has {count} subscriber(s)".format(
-                offer=BOLD_START + offer.name + BOLD_END,
-                count=BOLD_START + str(offer.num_subscribtions) + BOLD_END))
+            self.stdout.write(
+                "{offer} offer has {count} subscriber(s)".format(
+                    offer=BOLD_START + offer.name + BOLD_END,
+                    count=BOLD_START + str(offer.num_subscribtions) + BOLD_END
+                )
+            )

+ 98 - 58
coin/offers/models.py

@@ -16,14 +16,23 @@ class OfferManager(models.Manager):
         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.')]
+        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)
+        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()
+        return (
+            super(OfferManager, self).filter(rowlevelpermission__in=rowperms).distinct()
+        )
+
 
 class Offer(models.Model):
     """Description of an offer available to subscribers.
@@ -34,31 +43,50 @@ class Offer(models.Model):
     The choices list is dynamically generated at start in the __init__
     """
 
-    name = models.CharField(max_length=255, blank=False, null=False,
-                            verbose_name="nom de l'offre")
-    reference = models.CharField(max_length=255, blank=True,
-                                    verbose_name="référence de l'offre",
-                                    help_text="Identifiant a utiliser par exemple comme identifiant de virement")
-    configuration_type = models.CharField(max_length=50,
-                            blank=True,
-                            verbose_name='type de configuration',
-                            help_text="Type de configuration à utiliser avec cette offre")
-    billing_period = models.IntegerField(blank=False, null=False, default=1,
-                                         verbose_name='période de facturation',
-                                         help_text='en mois',
-                                         validators=[MinValueValidator(1)])
-    period_fees = models.DecimalField(max_digits=5, decimal_places=2,
-                                      blank=False, null=False,
-                                      verbose_name='montant par période de '
-                                                   'facturation',
-                                      help_text='en €')
-    initial_fees = models.DecimalField(max_digits=5, decimal_places=2,
-                                      blank=False, null=False,
-                                      verbose_name='frais de mise en service',
-                                      help_text='en €')
-    non_billable = models.BooleanField(default=False,
-                                       verbose_name='n\'est pas facturable',
-                                       help_text='L\'offre ne sera pas facturée par la commande charge_members')
+    name = models.CharField(
+        max_length=255, blank=False, null=False, verbose_name="nom de l'offre"
+    )
+    reference = models.CharField(
+        max_length=255,
+        blank=True,
+        verbose_name="référence de l'offre",
+        help_text="Identifiant a utiliser par exemple comme identifiant de virement",
+    )
+    configuration_type = models.CharField(
+        max_length=50,
+        blank=True,
+        verbose_name="type de configuration",
+        help_text="Type de configuration à utiliser avec cette offre",
+    )
+    billing_period = models.IntegerField(
+        blank=False,
+        null=False,
+        default=1,
+        verbose_name="période de facturation",
+        help_text="en mois",
+        validators=[MinValueValidator(1)],
+    )
+    period_fees = models.DecimalField(
+        max_digits=5,
+        decimal_places=2,
+        blank=False,
+        null=False,
+        verbose_name="montant par période de " "facturation",
+        help_text="en €",
+    )
+    initial_fees = models.DecimalField(
+        max_digits=5,
+        decimal_places=2,
+        blank=False,
+        null=False,
+        verbose_name="frais de mise en service",
+        help_text="en €",
+    )
+    non_billable = models.BooleanField(
+        default=False,
+        verbose_name="n'est pas facturable",
+        help_text="L'offre ne sera pas facturée par la commande charge_members",
+    )
 
     objects = OfferManager()
 
@@ -67,11 +95,13 @@ class Offer(models.Model):
         Renvoi le nom affichable du type de configuration
         """
         from coin.configuration.models import Configuration
+
         for item in Configuration.get_configurations_choices_list():
             if item and self.configuration_type in item:
                 return item[1]
         return self.configuration_type
-    get_configuration_type_display.short_description = 'type de configuration'
+
+    get_configuration_type_display.short_description = "type de configuration"
 
     def display_price(self):
         """Displays the price of an offer in a human-readable manner
@@ -86,15 +116,14 @@ class Offer(models.Model):
         else:
             period = self.billing_period
         return "{period_fee}€ / {billing_period} mois".format(
-            period_fee=fee,
-            billing_period=period)
+            period_fee=fee, billing_period=period
+        )
 
     def __unicode__(self):
-        return '{name} - {price}'.format(name=self.name,
-                                         price=self.display_price())
+        return "{name} - {price}".format(name=self.name, price=self.display_price())
 
     class Meta:
-        verbose_name = 'offre'
+        verbose_name = "offre"
 
 
 class OfferSubscriptionQuerySet(models.QuerySet):
@@ -107,15 +136,17 @@ class OfferSubscriptionQuerySet(models.QuerySet):
         if at_date is None:
             at_date = datetime.date.today()
 
-        return self.filter(Q(subscription_date__lte=at_date) &
-                           (Q(resign_date__gt=at_date) |
-                            Q(resign_date__isnull=True)))
+        return self.filter(
+            Q(subscription_date__lte=at_date)
+            & (Q(resign_date__gt=at_date) | Q(resign_date__isnull=True))
+        )
 
     def offer_summary(self):
         """ Agregates as a count of subscriptions per offer
         """
-        return self.values('offer__name', 'offer__reference').annotate(
-            num_subscriptions=Count('offer'))
+        return self.values("offer__name", "offer__reference").annotate(
+            num_subscriptions=Count("offer")
+        )
 
 
 class OfferSubscription(models.Model):
@@ -126,44 +157,53 @@ class OfferSubscription(models.Model):
     (technical configuration for the technology)) relate to this class
     with a OneToOneField
     """
+
     subscription_date = models.DateField(
         null=False,
         blank=False,
         default=datetime.date.today,
-        verbose_name="date de souscription à l'offre")
+        verbose_name="date de souscription à l'offre",
+    )
     # TODO: for data retention, prevent deletion of a subscription object
     # while the resign date is recent enough (e.g. one year in France).
     resign_date = models.DateField(
-        null=True,
-        blank=True,
-        verbose_name='date de résiliation')
+        null=True, blank=True, verbose_name="date de résiliation"
+    )
     # TODO: move this to offers?
-    commitment = models.IntegerField(blank=False, null=False,
-                                     verbose_name="période d'engagement",
-                                     help_text='en mois',
-                                     validators=[MinValueValidator(0)],
-                                     default=0)
-    comments = models.TextField(blank=True, verbose_name='commentaires',
-                                help_text="Commentaires libres (informations"
-                                " spécifiques concernant l'abonnement)")
-    member = models.ForeignKey('members.Member', verbose_name='membre')
-    offer = models.ForeignKey('Offer', verbose_name='offre')
+    commitment = models.IntegerField(
+        blank=False,
+        null=False,
+        verbose_name="période d'engagement",
+        help_text="en mois",
+        validators=[MinValueValidator(0)],
+        default=0,
+    )
+    comments = models.TextField(
+        blank=True,
+        verbose_name="commentaires",
+        help_text="Commentaires libres (informations"
+        " spécifiques concernant l'abonnement)",
+    )
+    member = models.ForeignKey("members.Member", verbose_name="membre")
+    offer = models.ForeignKey("Offer", verbose_name="offre")
 
     objects = OfferSubscriptionQuerySet().as_manager()
 
     def get_subscription_reference(self):
         return settings.SUBSCRIPTION_REFERENCE.format(subscription=self)
-    get_subscription_reference.short_description = 'Référence'
+
+    get_subscription_reference.short_description = "Référence"
 
     def __unicode__(self):
-        return '%s - %s - %s' % (self.member, self.offer.name,
-                                       self.subscription_date)
+        return "%s - %s - %s" % (self.member, self.offer.name, self.subscription_date)
 
     class Meta:
-        verbose_name = 'abonnement'
+        verbose_name = "abonnement"
 
 
 def count_active_subscriptions():
     today = datetime.date.today()
-    query = Q(subscription_date__lte=today) & (Q(resign_date__isnull=True) | Q(resign_date__gte=today))
+    query = Q(subscription_date__lte=today) & (
+        Q(resign_date__isnull=True) | Q(resign_date__gte=today)
+    )
     return OfferSubscription.objects.filter(query).count()

+ 28 - 22
coin/offers/offersubscription_filter.py

@@ -2,44 +2,50 @@
 from __future__ import unicode_literals
 
 from django.contrib.admin import SimpleListFilter
-from django.db.models import Q,F
+from django.db.models import Q, F
 import datetime
 
 
 class OfferSubscriptionTerminationFilter(SimpleListFilter):
-    title = 'Abonnement'
-    parameter_name = 'termination'
+    title = "Abonnement"
+    parameter_name = "termination"
 
     def lookups(self, request, model_admin):
         return (
-            ('not_terminated', 'Abonnements en cours'),
-            ('terminated', 'Abonnements résiliés'),
+            ("not_terminated", "Abonnements en cours"),
+            ("terminated", "Abonnements résiliés"),
         )
 
     def queryset(self, request, queryset):
-        if self.value() == 'not_terminated':
-            return queryset.filter(Q(resign_date__gt=datetime.date.today) | Q(resign_date__isnull=True))
-        if self.value() == 'terminated':
+        if self.value() == "not_terminated":
+            return queryset.filter(
+                Q(resign_date__gt=datetime.date.today) | Q(resign_date__isnull=True)
+            )
+        if self.value() == "terminated":
             return queryset.filter(resign_date__lte=datetime.date.today)
 
 
 class OfferSubscriptionCommitmentFilter(SimpleListFilter):
-    title = 'Engagement'
-    parameter_name = 'commitment'
+    title = "Engagement"
+    parameter_name = "commitment"
 
     def lookups(self, request, model_admin):
-        return (
-            ('committed', 'Est engagé'),
-            ('not_committed', 'N\'est plus engagé'),
-        )
+        return (("committed", "Est engagé"), ("not_committed", "N'est plus engagé"))
 
     def queryset(self, request, queryset):
-        if self.value() == 'committed':
-            # TODO : Faire mieux que du SQL écrit en dur. La ligne 
+        if self.value() == "committed":
+            # TODO : Faire mieux que du SQL écrit en dur. La ligne
             # en dessous ne fonctionne pas et je ne sais pas pourquoi
-            return queryset.extra(where = ["subscription_date + INTERVAL '1 month' * commitment > current_date"])
-            #~ return queryset.filter(subscription_date__gte=datetime.date.today - relativedelta(months=F('commitment'))) 
-        if self.value() == 'not_committed':
-            return queryset.extra(where = ["subscription_date + INTERVAL '1 month' * commitment <= current_date"])
-            #~ return queryset.filter(subscription_date__lte=datetime.date.today - relativedelta(months=F('commitment'))) 
-
+            return queryset.extra(
+                where=[
+                    "subscription_date + INTERVAL '1 month' * commitment > current_date"
+                ]
+            )
+            # ~ return queryset.filter(subscription_date__gte=datetime.date.today - relativedelta(months=F('commitment')))
+        if self.value() == "not_committed":
+            return queryset.extra(
+                where=[
+                    "subscription_date + INTERVAL '1 month' * commitment <= current_date"
+                ]
+            )
+            # ~ return queryset.filter(subscription_date__lte=datetime.date.today - relativedelta(months=F('commitment')))

+ 7 - 3
coin/offers/urls.py

@@ -5,8 +5,12 @@ from django.conf.urls import patterns, url
 from coin.offers.views import ConfigurationRedirectView, subscription_count_json
 
 urlpatterns = patterns(
-    '',
+    "",
     # Redirect to the appropriate configuration backend.
-    url(r'^configuration/(?P<id>.+)$', ConfigurationRedirectView.as_view(), name="configuration-redirect"),
-    url(r'^api/v1/count$', subscription_count_json),
+    url(
+        r"^configuration/(?P<id>.+)$",
+        ConfigurationRedirectView.as_view(),
+        name="configuration-redirect",
+    ),
+    url(r"^api/v1/count$", subscription_count_json),
 )

+ 35 - 16
coin/offers/views.py

@@ -13,17 +13,30 @@ from django.http import JsonResponse, HttpResponseServerError
 
 from coin.offers.models import Offer, OfferSubscription
 
+# This file could not be formatted by Black.
+
 class ConfigurationRedirectView(RedirectView):
-    """Redirects to the appropriate view for the configuration backend of the
-    specified subscription."""
+    """
+    Redirects to the appropriate view for the configuration backend of the
+    specified subscription.
+    """
 
     permanent = False
 
     def get_redirect_url(self, *args, **kwargs):
-        subscription = get_object_or_404(OfferSubscription, pk=self.kwargs['id'],
-                                         member=self.request.user)
-        return reverse(subscription.configuration.url_namespace + ':' + subscription.configuration.backend_name,
-                       args=[subscription.configuration.pk])
+        subscription = get_object_or_404(
+            OfferSubscription,
+            pk=self.kwargs["id"],
+            member=self.request.user
+        )
+        return reverse(
+            (
+                subscription.configuration.url_namespace +
+                ':' +
+                subscription.configuration.backend_name
+            ),
+            args=[subscription.configuration.pk]
+        )
 
 
 # @cache_control(max_age=7200)
@@ -31,23 +44,29 @@ def subscription_count_json(request):
     output = []
 
     # Get date form url, or set default
-    date = request.GET.get('date', datetime.date.today())
+    date = request.GET.get("date", datetime.date.today())
 
     # Validate date type
     if not isinstance(date, datetime.date):
         try:
-            datetime.datetime.strptime(date, '%Y-%m-%d')
+            datetime.datetime.strptime(date, "%Y-%m-%d")
         except ValueError, TypeError:
-            return HttpResponseServerError("Incorrect date format, should be YYYY-MM-DD")
+            return HttpResponseServerError(
+                "Incorrect date format, should be YYYY-MM-DD"
+            )
 
     # Get current offer subscription
-    offersubscriptions = list(OfferSubscription.objects.running(date).offer_summary())
+    offersubscriptions = list(
+        OfferSubscription.objects.running(date).offer_summary()
+    )
     for offersub in offersubscriptions:
-        output.append({
-            'reference' : offersub['offer__reference'],
-            'name' : offersub['offer__name'],
-            'subscriptions_count' : offersub['num_subscriptions']
-        })
+        output.append(
+            {
+                "reference" : offersub["offer__reference"],
+                "name" : offersub["offer__name"],
+                "subscriptions_count" : offersub["num_subscriptions"]
+            }
+        )
 
     # Return JSON
-    return JsonResponse(output, safe=False)
+    return JsonResponse(output, safe=False)

+ 7 - 6
coin/resources/admin.py

@@ -5,19 +5,20 @@ from django.contrib import admin
 
 from coin.resources.models import IPPool, IPSubnet
 
+
 class IPPoolAdmin(admin.ModelAdmin):
-    list_display = ('name', 'inet', 'default_subnetsize')
-    ordering = ('inet',)
+    list_display = ("name", "inet", "default_subnetsize")
+    ordering = ("inet",)
 
 
 # TODO: don't display "Delegate reverse DNS" checkbox and Nameservers when
 # creating/editing the object in the admin (since it is a purely
 # user-specific parameter)
 class IPSubnetAdmin(admin.ModelAdmin):
-    list_display = ('inet', 'ip_pool', 'configuration')
-    list_filter = ('ip_pool',)
-    search_fields = ('inet',)
-    ordering = ('inet',)
+    list_display = ("inet", "ip_pool", "configuration")
+    list_filter = ("ip_pool",)
+    search_fields = ("inet",)
+    ordering = ("inet",)
 
 
 admin.site.register(IPPool, IPPoolAdmin)

+ 83 - 35
coin/resources/models.py

@@ -10,29 +10,42 @@ from netaddr import IPSet
 
 class IPPool(models.Model):
     """Pool of IP addresses (either v4 or v6)."""
-    name = models.CharField(max_length=255, blank=False, null=False,
-                            verbose_name='nom',
-                            help_text="Nom du pool d'IP")
-    default_subnetsize = models.PositiveSmallIntegerField(blank=False,
-                                                          verbose_name='taille de sous-réseau par défaut',
-                                                          help_text='Taille par défaut du sous-réseau à allouer aux abonnés dans ce pool',
-                                                          validators=[MaxValueValidator(64)])
-    inet = CidrAddressField(verbose_name='réseau',
-                            help_text="Bloc d'adresses IP du pool")
+
+    name = models.CharField(
+        max_length=255,
+        blank=False,
+        null=False,
+        verbose_name="nom",
+        help_text="Nom du pool d'IP",
+    )
+    default_subnetsize = models.PositiveSmallIntegerField(
+        blank=False,
+        verbose_name="taille de sous-réseau par défaut",
+        help_text="Taille par défaut du sous-réseau à allouer aux abonnés dans ce pool",
+        validators=[MaxValueValidator(64)],
+    )
+    inet = CidrAddressField(
+        verbose_name="réseau", help_text="Bloc d'adresses IP du pool"
+    )
     objects = NetManager()
 
     def clean(self):
         if self.inet:
             max_subnetsize = 64 if self.inet.version == 6 else 32
             if not self.inet.prefixlen <= self.default_subnetsize <= max_subnetsize:
-                raise ValidationError('Taille de sous-réseau invalide')
+                raise ValidationError("Taille de sous-réseau invalide")
             # Check that related subnet are in the pool (useful when
             # modifying an existing pool that already has subnets
             # allocated in it)
-            incorrect = [str(subnet) for subnet in self.ipsubnet_set.all()
-                         if not subnet.inet in self.inet]
+            incorrect = [
+                str(subnet)
+                for subnet in self.ipsubnet_set.all()
+                if not subnet.inet in self.inet
+            ]
             if incorrect:
-                err = "Des sous-réseaux se retrouveraient en-dehors du bloc d'IP: {}".format(incorrect)
+                err = "Des sous-réseaux se retrouveraient en-dehors du bloc d'IP: {}".format(
+                    incorrect
+                )
                 raise ValidationError(err)
 
     def __unicode__(self):
@@ -44,21 +57,30 @@ class IPPool(models.Model):
 
 
 class IPSubnet(models.Model):
-    inet = CidrAddressField(blank=True,
-                            unique=True, verbose_name="sous-réseau",
-                            help_text="Laisser vide pour allouer automatiquement")
+    inet = CidrAddressField(
+        blank=True,
+        unique=True,
+        verbose_name="sous-réseau",
+        help_text="Laisser vide pour allouer automatiquement",
+    )
     objects = NetManager()
     ip_pool = models.ForeignKey(IPPool, verbose_name="pool d'IP")
-    configuration = models.ForeignKey('configuration.Configuration',
-                                      related_name='ip_subnet',
-                                      verbose_name='configuration')
-    delegate_reverse_dns = models.BooleanField(default=False,
-                                               verbose_name='déléguer le reverse DNS',
-                                               help_text='Déléguer la résolution DNS inverse de ce sous-réseau à un ou plusieurs serveurs de noms')
-    name_server = models.ManyToManyField('reverse_dns.NameServer',
-                                         blank=True,
-                                         verbose_name='serveur de noms',
-                                         help_text="Serveur de noms à qui déléguer la résolution DNS inverse")
+    configuration = models.ForeignKey(
+        "configuration.Configuration",
+        related_name="ip_subnet",
+        verbose_name="configuration",
+    )
+    delegate_reverse_dns = models.BooleanField(
+        default=False,
+        verbose_name="déléguer le reverse DNS",
+        help_text="Déléguer la résolution DNS inverse de ce sous-réseau à un ou plusieurs serveurs de noms",
+    )
+    name_server = models.ManyToManyField(
+        "reverse_dns.NameServer",
+        blank=True,
+        verbose_name="serveur de noms",
+        help_text="Serveur de noms à qui déléguer la résolution DNS inverse",
+    )
 
     def allocate(self):
         """Automatically allocate a free subnet"""
@@ -66,7 +88,11 @@ class IPSubnet(models.Model):
         used = IPSet((s.inet for s in self.ip_pool.ipsubnet_set.all()))
         free = pool.difference(used)
         # Generator for efficiency (we don't build the whole list)
-        available = (p for p in free.iter_cidrs() if p.prefixlen <= self.ip_pool.default_subnetsize)
+        available = (
+            p
+            for p in free.iter_cidrs()
+            if p.prefixlen <= self.ip_pool.default_subnetsize
+        )
         # TODO: for IPv4, get rid of the network and broadcast
         # addresses? Not really needed nowadays, and we usually don't
         # have a real subnet in practice (i.e. Ethernet segment), but
@@ -74,7 +100,9 @@ class IPSubnet(models.Model):
         try:
             first_free = available.next()
         except StopIteration:
-            raise ValidationError("Impossible d'allouer un sous-réseau : bloc d'IP rempli.")
+            raise ValidationError(
+                "Impossible d'allouer un sous-réseau : bloc d'IP rempli."
+            )
         # first_free is a subnet, but it might be too large for our needs.
         # This selects the first sub-subnet of the right size.
         self.inet = first_free.subnet(self.ip_pool.default_subnetsize, 1).next()
@@ -90,19 +118,39 @@ class IPSubnet(models.Model):
         # two requests, but the optimal solution will have to be retried once
         # we use django-netfields>=0.7
 
-        #conflicting = self.ip_pool.ipsubnet_set.filter(Q(inet__net_contained_or_equal=self.inet) |
+        # conflicting = self.ip_pool.ipsubnet_set.filter(Q(inet__net_contained_or_equal=self.inet) |
         #                                               Q(inet__net_contains_or_equals=self.inet)).exclude(id=self.id)
-        conflicting_contained = self.ip_pool.ipsubnet_set.filter(inet__net_contained_or_equal=self.inet).exclude(id=self.id)
-        conflicting_containing = self.ip_pool.ipsubnet_set.filter(inet__net_contains_or_equals=self.inet).exclude(id=self.id)
+        conflicting_contained = self.ip_pool.ipsubnet_set.filter(
+            inet__net_contained_or_equal=self.inet
+        ).exclude(id=self.id)
+        conflicting_containing = self.ip_pool.ipsubnet_set.filter(
+            inet__net_contains_or_equals=self.inet
+        ).exclude(id=self.id)
         if conflicting_contained or conflicting_containing:
-            conflicting = conflicting_contained if conflicting_contained else conflicting_containing
-            raise ValidationError("Le sous-réseau est en conflit avec des sous-réseaux existants: {}.".format(conflicting))
+            conflicting = (
+                conflicting_contained
+                if conflicting_contained
+                else conflicting_containing
+            )
+            raise ValidationError(
+                "Le sous-réseau est en conflit avec des sous-réseaux existants: {}.".format(
+                    conflicting
+                )
+            )
 
     def validate_reverse_dns(self):
         """Check that reverse DNS entries, if any, are included in the subnet"""
-        incorrect = [str(rev.ip) for rev in self.reversednsentry_set.all() if not rev.ip in self.inet]
+        incorrect = [
+            str(rev.ip)
+            for rev in self.reversednsentry_set.all()
+            if not rev.ip in self.inet
+        ]
         if incorrect:
-            raise ValidationError("Des entrées DNS inverse ne sont pas dans le sous-réseau: {}.".format(incorrect))
+            raise ValidationError(
+                "Des entrées DNS inverse ne sont pas dans le sous-réseau: {}.".format(
+                    incorrect
+                )
+            )
 
     def clean(self):
         if not self.inet:

+ 1 - 0
coin/resources/templatetags/subnets.py

@@ -7,6 +7,7 @@ from netaddr import IPNetwork
 
 register = template.Library()
 
+
 @register.filter
 def prettify(subnet):
     """Prettify an IPv4 subnet by remove the subnet length when it is equal to /32

+ 2 - 2
coin/reverse_dns/admin.py

@@ -5,5 +5,5 @@ from django.contrib import admin
 
 from coin.reverse_dns.models import NameServer, ReverseDNSEntry
 
-admin.site.register(NameServer,)
-admin.site.register(ReverseDNSEntry,)
+admin.site.register(NameServer)
+admin.site.register(ReverseDNSEntry)

+ 38 - 23
coin/reverse_dns/models.py

@@ -10,53 +10,68 @@ import ldapdb.models
 from ldapdb.models.fields import CharField, IntegerField, ListField
 
 # TODO: validate DNS names with this regex
-REGEX = r'(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}|[A-Z0-9-]{2,})\.?$'
+REGEX = r"(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}|[A-Z0-9-]{2,})\.?$"
+
 
 class NameServer(models.Model):
     # TODO: signal to IPSubnet when we are modified, so that is saves the
     # result into LDAP.  Actually, better: build a custom M2M relation
     # between NameServer and IPSubnet (see Capslock), and save in LDAP
     # there.
-    dns_name = models.CharField(max_length=255,
-                                verbose_name="nom du serveur",
-                                help_text="Exemple : ns1.example.com")
-    description = models.CharField(max_length=255, blank=True,
-                                   verbose_name="description du serveur",
-                                   help_text="Exemple : Mon serveur de noms principal")
+    dns_name = models.CharField(
+        max_length=255,
+        verbose_name="nom du serveur",
+        help_text="Exemple : ns1.example.com",
+    )
+    description = models.CharField(
+        max_length=255,
+        blank=True,
+        verbose_name="description du serveur",
+        help_text="Exemple : Mon serveur de noms principal",
+    )
     owner = models.ForeignKey("members.Member", verbose_name="propriétaire")
 
     def __unicode__(self):
-        return "{} ({})".format(self.description, self.dns_name) if self.description else self.dns_name
+        return (
+            "{} ({})".format(self.description, self.dns_name)
+            if self.description
+            else self.dns_name
+        )
 
     class Meta:
-        verbose_name = 'serveur de noms'
-        verbose_name_plural = 'serveurs de noms'
+        verbose_name = "serveur de noms"
+        verbose_name_plural = "serveurs de noms"
 
 
 class ReverseDNSEntry(models.Model):
-    ip = InetAddressField(unique=True, verbose_name='adresse IP')
-    reverse = models.CharField(max_length=255, verbose_name='reverse',
-                               help_text="Nom à associer à l'adresse IP")
-    ttl = models.IntegerField(default=3600, verbose_name="TTL",
-                              help_text="en secondes",
-                              validators=[MinValueValidator(60)])
-    ip_subnet = models.ForeignKey('resources.IPSubnet',
-                                  verbose_name='sous-réseau IP')
+    ip = InetAddressField(unique=True, verbose_name="adresse IP")
+    reverse = models.CharField(
+        max_length=255,
+        verbose_name="reverse",
+        help_text="Nom à associer à l'adresse IP",
+    )
+    ttl = models.IntegerField(
+        default=3600,
+        verbose_name="TTL",
+        help_text="en secondes",
+        validators=[MinValueValidator(60)],
+    )
+    ip_subnet = models.ForeignKey("resources.IPSubnet", verbose_name="sous-réseau IP")
 
     objects = NetManager()
 
     def clean(self):
         if self.reverse:
             # Check that the reverse ends with a "." (add it if necessary)
-            if not self.reverse.endswith('.'):
-                self.reverse += '.'
+            if not self.reverse.endswith("."):
+                self.reverse += "."
         if self.ip:
             if not self.ip in self.ip_subnet.inet:
-                raise ValidationError('IP address must be included in the IP subnet.')
+                raise ValidationError("IP address must be included in the IP subnet.")
 
     def __unicode__(self):
         return "{} → {}".format(self.ip, self.reverse)
 
     class Meta:
-        verbose_name = 'entrée DNS inverse'
-        verbose_name_plural = 'entrées DNS inverses'
+        verbose_name = "entrée DNS inverse"
+        verbose_name_plural = "entrées DNS inverses"

+ 87 - 98
coin/settings_base.py

@@ -20,14 +20,14 @@ MANAGERS = ADMINS
 
 DATABASES = {
     # Database hosted on vagant test box
-    'default': {
-        'ENGINE': 'django.db.backends.postgresql_psycopg2',
-        'NAME': 'coin',
-        'USER': 'coin',
-        'PASSWORD': 'coin',
-        'HOST': 'localhost',  # Empty for localhost through domain sockets
-        'PORT': '15432',  # Empty for default
-    },
+    "default": {
+        "ENGINE": "django.db.backends.postgresql_psycopg2",
+        "NAME": "coin",
+        "USER": "coin",
+        "PASSWORD": "coin",
+        "HOST": "localhost",  # Empty for localhost through domain sockets
+        "PORT": "15432",  # Empty for default
+    }
 }
 
 # Hosts/domain names that are valid for this site; required if DEBUG is False
@@ -38,11 +38,11 @@ ALLOWED_HOSTS = []
 # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
 # although not all choices may be available on all operating systems.
 # In a Windows environment this must be set to your system time zone.
-TIME_ZONE = 'Europe/Paris'
+TIME_ZONE = "Europe/Paris"
 
 # Language code for this installation. All choices can be found here:
 # http://www.i18nguy.com/unicode/language-identifiers.html
-LANGUAGE_CODE = 'fr-fr'
+LANGUAGE_CODE = "fr-fr"
 
 SITE_ID = 1
 
@@ -58,28 +58,28 @@ USE_L10N = True
 USE_TZ = True
 
 # Default URL for login and logout
-LOGIN_URL = '/members/login'
-LOGIN_REDIRECT_URL = '/members'
-LOGOUT_URL = '/members/logout'
+LOGIN_URL = "/members/login"
+LOGIN_REDIRECT_URL = "/members"
+LOGOUT_URL = "/members/logout"
 
 # Absolute filesystem path to the directory that will hold user-uploaded files.
 # Example: "/var/www/example.com/media/"
-MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')
+MEDIA_ROOT = os.path.join(BASE_DIR, "media/")
 
 # URL that handles the media served from MEDIA_ROOT. Make sure to use a
 # trailing slash.
 # Examples: "http://example.com/media/", "http://media.example.com/"
-MEDIA_URL = '/media/'
+MEDIA_URL = "/media/"
 
 # Absolute path to the directory static files should be collected to.
 # Don't put anything in this directory yourself; store your static files
 # in apps' "static/" subdirectories and in STATICFILES_DIRS.
 # Example: "/var/www/example.com/static/"
-STATIC_ROOT = os.path.join(BASE_DIR, 'static/')
+STATIC_ROOT = os.path.join(BASE_DIR, "static/")
 
 # URL prefix for static files.
 # Example: "http://example.com/static/", "http://static.example.com/"
-STATIC_URL = '/static/'
+STATIC_URL = "/static/"
 
 # Additional locations of static files
 STATICFILES_DIRS = (
@@ -91,79 +91,79 @@ STATICFILES_DIRS = (
 # List of finder classes that know how to find static files in
 # various locations.
 STATICFILES_FINDERS = (
-    'django.contrib.staticfiles.finders.FileSystemFinder',
-    'django.contrib.staticfiles.finders.AppDirectoriesFinder',
-    #'django.contrib.staticfiles.finders.DefaultStorageFinder',
+    "django.contrib.staticfiles.finders.FileSystemFinder",
+    "django.contrib.staticfiles.finders.AppDirectoriesFinder",
+    # 'django.contrib.staticfiles.finders.DefaultStorageFinder',
 )
 
 # Location of private files. (Like invoices)
 # In production, this location should not be publicly accessible through
 # the web server
-PRIVATE_FILES_ROOT = os.path.join(BASE_DIR, 'smedia/')
+PRIVATE_FILES_ROOT = os.path.join(BASE_DIR, "smedia/")
 
 # Backend to use when sending private files to client
 # In production, must be sendfile.backends.xsendfile with Apache xsend file mod
 # Or failing xsendfile, use : sendfile.backends.simple
 # https://github.com/johnsensible/django-sendfile
-SENDFILE_BACKEND = 'sendfile.backends.development'
+SENDFILE_BACKEND = "sendfile.backends.development"
 
 # Make this unique, and don't share it with anybody.
-SECRET_KEY = '!qy_)gao6q)57#mz1s-d$5^+dp1nt=lk1d19&9bb3co37vn)!3'
+SECRET_KEY = "!qy_)gao6q)57#mz1s-d$5^+dp1nt=lk1d19&9bb3co37vn)!3"
 
 # List of callables that know how to import templates from various sources.
 TEMPLATE_LOADERS = (
-    'django.template.loaders.filesystem.Loader',
-    'django.template.loaders.app_directories.Loader',
-    #'django.template.loaders.eggs.Loader',
+    "django.template.loaders.filesystem.Loader",
+    "django.template.loaders.app_directories.Loader",
+    # 'django.template.loaders.eggs.Loader',
 )
 
 MIDDLEWARE_CLASSES = (
-    'django.middleware.common.CommonMiddleware',
-    'django.contrib.sessions.middleware.SessionMiddleware',
-    'django.middleware.csrf.CsrfViewMiddleware',
-    'django.contrib.auth.middleware.AuthenticationMiddleware',
-    'django.contrib.messages.middleware.MessageMiddleware',
+    "django.middleware.common.CommonMiddleware",
+    "django.contrib.sessions.middleware.SessionMiddleware",
+    "django.middleware.csrf.CsrfViewMiddleware",
+    "django.contrib.auth.middleware.AuthenticationMiddleware",
+    "django.contrib.messages.middleware.MessageMiddleware",
     # Uncomment the next line for simple clickjacking protection:
     # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
 )
 
-ROOT_URLCONF = 'coin.urls'
+ROOT_URLCONF = "coin.urls"
 
 # Python dotted path to the WSGI application used by Django's runserver.
-WSGI_APPLICATION = 'coin.wsgi.application'
+WSGI_APPLICATION = "coin.wsgi.application"
 
 TEMPLATE_DIRS = (
     # Only absolute paths, always forward slashes
-    os.path.join(PROJECT_PATH, 'templates/'),
+    os.path.join(PROJECT_PATH, "templates/"),
 )
 
 EXTRA_TEMPLATE_DIRS = tuple()
 
 INSTALLED_APPS = (
-    'django.contrib.auth',
-    'django.contrib.contenttypes',
-    'django.contrib.sessions',
-    'django.contrib.sites',
-    'ldapdb',  # LDAP as database backend
-    'django.contrib.messages',
-    'django.contrib.staticfiles',
+    "django.contrib.auth",
+    "django.contrib.contenttypes",
+    "django.contrib.sessions",
+    "django.contrib.sites",
+    "ldapdb",  # LDAP as database backend
+    "django.contrib.messages",
+    "django.contrib.staticfiles",
     # Uncomment the next line to enable the admin:
-    'django.contrib.admin',
-    'netfields',
+    "django.contrib.admin",
+    "netfields",
     # Uncomment the next line to enable admin documentation:
-    #'django.contrib.admindocs',
-    'polymorphic',
+    # 'django.contrib.admindocs',
+    "polymorphic",
     # 'south',
-    'autocomplete_light', #Automagic autocomplete foreingkey form component
-    'activelink', #Detect if a link match actual page
-    'coin',
-    'coin.members',
-    'coin.offers',
-    'coin.billing',
-    'coin.resources',
-    'coin.reverse_dns',
-    'coin.configuration',
-    'coin.isp_database',
+    "autocomplete_light",  # Automagic autocomplete foreingkey form component
+    "activelink",  # Detect if a link match actual page
+    "coin",
+    "coin.members",
+    "coin.offers",
+    "coin.billing",
+    "coin.resources",
+    "coin.reverse_dns",
+    "coin.configuration",
+    "coin.isp_database",
 )
 
 EXTRA_INSTALLED_APPS = tuple()
@@ -174,40 +174,30 @@ EXTRA_INSTALLED_APPS = tuple()
 # See http://docs.djangoproject.com/en/dev/topics/logging for
 # more details on how to customize your logging configuration.
 LOGGING = {
-    'version': 1,
-    'disable_existing_loggers': False,
-    'formatters': {
-    },
-    'filters': {
-        'require_debug_false': {
-            '()': 'django.utils.log.RequireDebugFalse'
-        }
-    },
-    'handlers': {
-        'mail_admins': {
-            'level': 'ERROR',
-            'filters': ['require_debug_false'],
-            'class': 'django.utils.log.AdminEmailHandler'
-        },
-        'console': {
-            'class': 'logging.StreamHandler',
+    "version": 1,
+    "disable_existing_loggers": False,
+    "formatters": {},
+    "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}},
+    "handlers": {
+        "mail_admins": {
+            "level": "ERROR",
+            "filters": ["require_debug_false"],
+            "class": "django.utils.log.AdminEmailHandler",
         },
+        "console": {"class": "logging.StreamHandler"},
     },
-    'loggers': {
-        'django.request': {
-            'handlers': ['mail_admins'],
-            'level': 'ERROR',
-            'propagate': True,
+    "loggers": {
+        "django.request": {
+            "handlers": ["mail_admins"],
+            "level": "ERROR",
+            "propagate": True,
         },
-        'django': {
-            'handlers': ['console'],
-            'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
+        "django": {
+            "handlers": ["console"],
+            "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"),
         },
-        "coin.billing": {
-            'handlers': ['console'],
-            'level': 'INFO',
-        }
-    }
+        "coin.billing": {"handlers": ["console"], "level": "INFO"},
+    },
 }
 
 TEMPLATE_CONTEXT_PROCESSORS = (
@@ -220,22 +210,21 @@ TEMPLATE_CONTEXT_PROCESSORS = (
     "django.core.context_processors.request",
     "coin.isp_database.context_processors.branding",
     "coin.context_processors.installed_apps",
-    "django.contrib.messages.context_processors.messages")
+    "django.contrib.messages.context_processors.messages",
+)
 
-AUTH_USER_MODEL = 'members.Member'
+AUTH_USER_MODEL = "members.Member"
 
-AUTHENTICATION_BACKENDS = (
-    'django.contrib.auth.backends.ModelBackend',
-)
+AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",)
 
-TEST_RUNNER = 'django.test.runner.DiscoverRunner'
+TEST_RUNNER = "django.test.runner.DiscoverRunner"
 
 GRAPHITE_SERVER = "http://localhost"
 
 # Configuration for outgoing emails
-#DEFAULT_FROM_EMAIL = "coin@example.com"
-#EMAIL_USE_TLS = True
-#EMAIL_HOST = "smtp.chezmoi.tld"
+# DEFAULT_FROM_EMAIL = "coin@example.com"
+# EMAIL_USE_TLS = True
+# EMAIL_HOST = "smtp.chezmoi.tld"
 
 # Do we use LDAP or not
 LDAP_ACTIVATE = False
@@ -250,11 +239,11 @@ MEMBER_DEFAULT_COTISATION = 20
 
 # Link to a page with information on how to become a member or pay the
 # membership fee
-MEMBER_MEMBERSHIP_INFO_URL = ''
+MEMBER_MEMBERSHIP_INFO_URL = ""
 
 # Pattern used to display a unique reference for any subscription
 # Helpful for bank wire transfer identification
-SUBSCRIPTION_REFERENCE = 'REF-{subscription.offer.reference}-{subscription.pk}'
+SUBSCRIPTION_REFERENCE = "REF-{subscription.offer.reference}-{subscription.pk}"
 
 # Payment delay in days
 PAYMENT_DELAY = 30
@@ -266,8 +255,8 @@ SESSION_COOKIE_AGE = 7200
 # feed name (used in template), url, max entries to display
 # "isp" entry gets picked automatically in default index template
 FEEDS = (
-    ('ffdn', 'http://www.ffdn.org/fr/rss.xml', 3),
-#    ('isp', 'http://isp.example.com/feed/', 3),
+    ("ffdn", "http://www.ffdn.org/fr/rss.xml", 3),
+    #    ('isp', 'http://isp.example.com/feed/', 3),
 )
 
 # Member can edit their own data

+ 21 - 21
coin/settings_local.example-illyse.py

@@ -1,9 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-EXTRA_INSTALLED_APPS = (
-    'vpn',
-)
+EXTRA_INSTALLED_APPS = ("vpn",)
 
 LDAP_ACTIVATE = True
 
@@ -16,22 +14,22 @@ ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
 
 DATABASES = {
     # Base de donnée du SI
-    'default': {
-        'ENGINE': 'django.db.backends.postgresql_psycopg2',
-        'NAME': 'illyse_coin',
-        'USER': 'illyse_coin',
-        'PASSWORD': '',
-        'HOST': '',  # Empty for localhost through domain sockets
-        'PORT': '',  # Empty for default
+    "default": {
+        "ENGINE": "django.db.backends.postgresql_psycopg2",
+        "NAME": "illyse_coin",
+        "USER": "illyse_coin",
+        "PASSWORD": "",
+        "HOST": "",  # Empty for localhost through domain sockets
+        "PORT": "",  # Empty for default
     },
     # LDAP backend pour stockage et mise à jour de données
-    'ldap': {
-        'ENGINE': 'ldapdb.backends.ldap',
-        'NAME': 'ldap://ldapdev.illyse.org:389/',
-        'TLS': True,
-        'USER': 'cn=illysedev,ou=services,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR',
-        'PASSWORD': 'gfj83-E8ECgGh23JK_Ol12'
-    }
+    "ldap": {
+        "ENGINE": "ldapdb.backends.ldap",
+        "NAME": "ldap://ldapdev.illyse.org:389/",
+        "TLS": True,
+        "USER": "cn=illysedev,ou=services,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR",
+        "PASSWORD": "gfj83-E8ECgGh23JK_Ol12",
+    },
 }
 
 # LDAP Base DNs
@@ -42,13 +40,15 @@ VPN_CONF_BASE_DN = "ou=vpn,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
 # First UID to use for users
 LDAP_USER_FIRST_UID = 2000
 
-DATABASE_ROUTERS = ['ldapdb.router.Router']
+DATABASE_ROUTERS = ["ldapdb.router.Router"]
 
 GRAPHITE_SERVER = "http://graphite-dev.illyse.org"
 
 DEFAULT_FROM_EMAIL = "adminsys@illyse.org"
 
-FEEDS = (('isp', 'http://www.illyse.net/feed/', 3),
-         ('ffdn', 'http://www.ffdn.org/fr/rss.xml', 3))
+FEEDS = (
+    ("isp", "http://www.illyse.net/feed/", 3),
+    ("ffdn", "http://www.ffdn.org/fr/rss.xml", 3),
+)
 
-MEMBER_MEMBERSHIP_INFO_URL = 'https://www.illyse.org/projects/failocal/wiki/Cotisation'
+MEMBER_MEMBERSHIP_INFO_URL = "https://www.illyse.org/projects/failocal/wiki/Cotisation"

+ 1 - 4
coin/settings_test.py

@@ -2,10 +2,7 @@ from settings_base import *
 
 # settings for unit tests
 
-EXTRA_INSTALLED_APPS = (
-    'hardware_provisioning',
-    'vpn',
-)
+EXTRA_INSTALLED_APPS = ("hardware_provisioning", "vpn")
 
 TEMPLATE_DIRS = EXTRA_TEMPLATE_DIRS + TEMPLATE_DIRS
 INSTALLED_APPS = INSTALLED_APPS + EXTRA_INSTALLED_APPS

+ 13 - 18
coin/urls.py

@@ -11,9 +11,11 @@ from coin import views
 import coin.apps
 
 import autocomplete_light
+
 autocomplete_light.autodiscover()
 
 from django.contrib import admin
+
 admin.autodiscover()
 
 from coin.isp_database.views import isp_json
@@ -25,27 +27,20 @@ def apps_urlpatterns():
     for app_config in apps.get_app_configs():
         if isinstance(app_config, coin.apps.AppURLs):
             for prefix, pats in app_config.exported_urlpatterns:
-                yield url(
-                    r'^{}/'.format(prefix),
-                    include(pats, namespace=prefix))
-
-urlpatterns = patterns(
-    '',
-    url(r'^$', 'coin.members.views.index', name='home'),
-
-    url(r'^isp.json$', isp_json),
-    url(r'^members/', include('coin.members.urls', namespace='members')),
-    url(r'^billing/', include('coin.billing.urls', namespace='billing')),
-    url(r'^subscription/', include('coin.offers.urls', namespace='subscription')),
+                yield url(r"^{}/".format(prefix), include(pats, namespace=prefix))
 
-    url(r'^admin/', include(admin.site.urls)),
 
+urlpatterns = patterns(
+    "",
+    url(r"^$", "coin.members.views.index", name="home"),
+    url(r"^isp.json$", isp_json),
+    url(r"^members/", include("coin.members.urls", namespace="members")),
+    url(r"^billing/", include("coin.billing.urls", namespace="billing")),
+    url(r"^subscription/", include("coin.offers.urls", namespace="subscription")),
+    url(r"^admin/", include(admin.site.urls)),
     # url(r'^admin/doc/', include('django.contrib.admindocs.urls')),
-
-    url(r'^feed/(?P<feed_name>.+)', views.feed, name='feed'),
-
-    url(r'^autocomplete/', include('autocomplete_light.urls')),
-
+    url(r"^feed/(?P<feed_name>.+)", views.feed, name="feed"),
+    url(r"^autocomplete/", include("autocomplete_light.urls")),
 )
 
 urlpatterns += staticfiles_urlpatterns()

+ 27 - 17
coin/utils.py

@@ -25,19 +25,22 @@ from django.contrib.sites.models import Site
 private_files_storage = FileSystemStorage(location=settings.PRIVATE_FILES_ROOT)
 
 # regexp which matches for ex irc://irc.example.tld/#channel
-re_chat_url = re.compile(r'(?P<protocol>\w+://)(?P<server>[\w\.]+)/(?P<channel>.*)')
+re_chat_url = re.compile(r"(?P<protocol>\w+://)(?P<server>[\w\.]+)/(?P<channel>.*)")
+
 
 def str_or_none(obj):
     return str(obj) if obj else None
 
+
 def rstrip_str(s, suffix):
     """Return a copy of the string [s] with the string [suffix] removed from
     the end (if [s] ends with [suffix], otherwise return s)."""
     if s.endswith(suffix):
-        return s[:-len(suffix)]
+        return s[: -len(suffix)]
     else:
         return s
 
+
 def ldap_hash(password):
     """Hash a password for use with LDAP.  If the password is already hashed,
     do nothing.
@@ -46,15 +49,17 @@ def ldap_hash(password):
     we have to encode/decode it as needed to switch between unicode and
     bytes.  The code should work fine with both python2 and python3.
     """
-    if password and not password.startswith('{SSHA}'):
+    if password and not password.startswith("{SSHA}"):
         salt = binascii.hexlify(os.urandom(8))
         digest = hashlib.sha1(password.encode("utf-8") + salt).digest()
-        return '{SSHA}' + base64.b64encode(digest + salt).decode("utf-8")
+        return "{SSHA}" + base64.b64encode(digest + salt).decode("utf-8")
     else:
         return password
 
 
-def send_templated_email(to, subject_template, body_template, context={}, attachements=[], **kwargs):
+def send_templated_email(
+    to, subject_template, body_template, context={}, attachements=[], **kwargs
+):
     """
     Send a multialternative email based on html and optional txt template.
 
@@ -68,26 +73,26 @@ def send_templated_email(to, subject_template, body_template, context={}, attach
         attachements = [attachements]
 
     # Add domain in context
-    context['domain'] = Site.objects.get_current()
+    context["domain"] = Site.objects.get_current()
 
     # If .html/.txt is specified in template name remove it
-    body_template = body_template.split('.')[0]
-    subject_template = subject_template.split('.')[0]
+    body_template = body_template.split(".")[0]
+    subject_template = subject_template.split(".")[0]
 
     # Get html template for body, fail if not exists
-    template_html = get_template('%s.html' % (body_template,))
+    template_html = get_template("%s.html" % (body_template,))
     html_content = template_html.render(Context(context))
 
     # Get txt template for subject, fail if not exists
-    subject_template = get_template('%s.txt' % (subject_template,))
+    subject_template = get_template("%s.txt" % (subject_template,))
     subject = subject_template.render(Context(context))
     # Get rid of newlines
-    subject = subject.strip().replace('\n', '')
+    subject = subject.strip().replace("\n", "")
 
     # Try to get a txt version, convert from html to markdown style
     # (using html2text) if fail
     try:
-        template_txt = get_template('%s.txt' % (body_template,))
+        template_txt = get_template("%s.txt" % (body_template,))
         text_content = template_txt.render_to_string(Context(context))
     except TemplateDoesNotExist:
         text_content = html2text.html2text(html_content)
@@ -110,6 +115,7 @@ def delete_selected(modeladmin, request, queryset):
     for obj in queryset:
         obj.delete()
 
+
 delete_selected.short_description = "Supprimer tous les objets sélectionnés."
 
 # Time-related functions
@@ -130,6 +136,7 @@ def end_of_month():
     else:
         return date(today.year, today.month + 1, 1) - timedelta(days=1)
 
+
 @contextmanager
 def respect_language(language):
     """Context manager that changes the current translation language for
@@ -153,21 +160,25 @@ def respect_language(language):
 
 def respects_language(fun):
     """Associated decorator"""
+
     @wraps(fun)
     def _inner(*args, **kwargs):
-        with respect_language(kwargs.pop('language', None)):
+        with respect_language(kwargs.pop("language", None)):
             return fun(*args, **kwargs)
+
     return _inner
 
 
 def disable_for_loaddata(signal_handler):
     """Decorator for post_save events that disables them when loading
     data from fixtures."""
+
     @wraps(signal_handler)
     def wrapper(*args, **kwargs):
-        if kwargs['raw']:
+        if kwargs["raw"]:
             return
         signal_handler(*args, **kwargs)
+
     return wrapper
 
 
@@ -183,10 +194,9 @@ def postgresql_regexp(regexp):
     except AttributeError:
         original_pattern = regexp
 
-    return re.sub(
-        r'\?P<.*?>', '', original_pattern)
+    return re.sub(r"\?P<.*?>", "", original_pattern)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     # ldap_hash expects an unicode string
     print(ldap_hash(sys.argv[1].decode("utf-8")))

+ 4 - 4
coin/validation.py

@@ -9,14 +9,14 @@ from .utils import re_chat_url
 
 def validate_v4(address):
     if address.version != 4:
-        raise ValidationError('{} is not an IPv4 address'.format(address))
+        raise ValidationError("{} is not an IPv4 address".format(address))
 
 
 def validate_v6(address):
     if address.version != 6:
-        raise ValidationError('{} is not an IPv6 address'.format(address))
+        raise ValidationError("{} is not an IPv6 address".format(address))
 
 
 chatroom_url_validator = RegexValidator(
-    regex=re_chat_url,
-    message="Enter a value of the form  <proto>://<server>/<channel>")
+    regex=re_chat_url, message="Enter a value of the form  <proto>://<server>/<channel>"
+)

+ 11 - 9
coin/views.py

@@ -11,23 +11,23 @@ from django.http import HttpResponse, HttpResponseNotFound, HttpResponseServerEr
 from django.conf import settings
 
 
-@cache_page(60 * 60 * 24) # Cache 24h
+@cache_page(60 * 60 * 24)  # Cache 24h
 def feed(request, feed_name):
     feeds = settings.FEEDS
     feed = None
     # Recherce le flux passé en paramètre dans les flux définis dans settings
     for feed_search in feeds:
-        if (feed_search[0] == feed_name):
+        if feed_search[0] == feed_name:
             feed = feed_search
             break
 
     # Si le flux n'a pas été trouvé ou qu'il n'y a pas d'URL donnée, renvoi 404
-    if not feed or len(feed)<2 or not feed[1]:
-        return HttpResponseNotFound('')
+    if not feed or len(feed) < 2 or not feed[1]:
+        return HttpResponseNotFound("")
     # Sinon récupère les informations (url et limit)
     else:
         feed_url = feed[1]
-        if len(feed) >=3:
+        if len(feed) >= 3:
             limit = feed[2]
         else:
             limit = 3
@@ -36,8 +36,10 @@ def feed(request, feed_name):
         feed = feedparser.parse(feed_url)
         entries = feed.entries[:limit]
 
-        return render_to_response('fragments/feed.html',
-                                  {'feed_entries': entries},
-                                  context_instance=RequestContext(request))
+        return render_to_response(
+            "fragments/feed.html",
+            {"feed_entries": entries},
+            context_instance=RequestContext(request),
+        )
     except:
-        return HttpResponseServerError('')
+        return HttpResponseServerError("")

+ 1 - 0
coin/wsgi.py

@@ -25,6 +25,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "coin.settings")
 # file. This includes Django's development server, if the WSGI_APPLICATION
 # setting points here.
 from django.core.wsgi import get_wsgi_application
+
 application = get_wsgi_application()
 
 # Apply WSGI middleware here.

+ 58 - 20
contrib/vpn_acct.py

@@ -10,6 +10,8 @@ import pyinotify
 sock = socket.socket()
 last_timestamp = 0
 
+# This file could not be formatted by Black.
+
 def logging(message, level = syslog.LOG_INFO):
     syslog.syslog(level, message)
     #print message
@@ -34,8 +36,10 @@ def read_vpn_acct_file(filename):
         # format: username ip qos uptxbytes downrxbytes
         if len(d) == 5:
             metrics.extend([
-                ( '%s.%s.uptxbytes'   % (options.prefix, d[0]), (tstamp, int(d[3])) ),
-                ( '%s.%s.downrxbytes' % (options.prefix, d[0]), (tstamp, int(d[4])) ),
+                ('%s.%s.uptxbytes' %
+                (options.prefix, d[0]), (tstamp, int(d[3]))),
+                ('%s.%s.downrxbytes' %
+                (options.prefix, d[0]), (tstamp, int(d[4]))),
             ])
     return tstamp, metrics
 
@@ -50,7 +54,7 @@ def send_to_carbon(metrics):
     
 
 def process_vpn_file(filename, old_tstamp = 0):
-    logging("Processing VPN file %s..." % (filename, ), syslog.LOG_DEBUG)
+    logging("Processing VPN file %s..." % (filename,), syslog.LOG_DEBUG)
 
     global last_timestamp
     tstamp, metrics = read_vpn_acct_file(filename)
@@ -68,31 +72,48 @@ def handle_old_vpn_files(directory, old_tstamp):
     # comparison when handling the matching files (see process_vpn_file() function).
     
     old_date = datetime.fromtimestamp(old_tstamp-60)
-    logging("Starting processing deferred files starting at %s..." % (old_date.strftime('%Y%m%d%H%M%S'), ), syslog.LOG_DEBUG)
+    logging(
+        "Starting processing deferred files starting at %s..." %
+        (old_date.strftime('%Y%m%d%H%M%S'),),
+        syslog.LOG_DEBUG
+    )
     for f in iglob(directory + '/*'):
         try:
-            tmp_tstamp = datetime.strptime(os.path.basename(f).split('-')[0], '%Y%m%d%H%M%S')
+            tmp_tstamp = datetime.strptime(
+                os.path.basename(f).split('-')[0],
+                '%Y%m%d%H%M%S'
+            )
         except ValueError:
             # Bad filename format: likely not an accounting file...
             pass
         else:
             if tmp_tstamp > old_date:
-                logging("Processing deferred file %s..." % (f, ), syslog.LOG_DEBUG)
+                logging(
+                    "Processing deferred file %s..." % (f,),
+                    syslog.LOG_DEBUG
+                )
                 process_vpn_file(f, old_tstamp)
 
 
 class VPNAcctHandler(pyinotify.ProcessEvent):
-
     def process_IN_MOVED_TO(self, event):
         self.handle_file(event)
+
     def process_IN_CLOSE_WRITE(self, event):
         self.handle_file(event)
+
     def handle_file(self, event):
-        logging("Event detected for file %s..." % (event.pathname, ), syslog.LOG_DEBUG)
+        logging(
+            "Event detected for file %s..." % (event.pathname,),
+            syslog.LOG_DEBUG
+        )
 
         if last_timestamp > 0:
             # Error ongoing: will wait till the connection is back (and last_timestamp == 0)
-            logging("Error ongoing: %s will be processed later..." % (event.pathname, ), syslog.LOG_DEBUG)
+            logging(
+                "Error ongoing: %s will be processed later..." % (event.pathname,),
+                syslog.LOG_DEBUG
+            )
             return
 
         process_vpn_file(event.pathname)
@@ -113,19 +134,26 @@ if __name__ == '__main__':
     parser.add_argument('-p', '--port', dest='port', type=int, default=2004, help='Carbon daemon port')
     options = parser.parse_args()
 
-    logging("Connecting to Carbon agent on %(server)s on port %(port)d..." %
-        { 'server': options.server, 'port': options.port, }, syslog.LOG_DEBUG)
+    logging(
+        "Connecting to Carbon agent on %(server)s on port %(port)d..." %
+        {"server": options.server, "port": options.port},
+        syslog.LOG_DEBUG
+    )
     try:
         sock = socket.create_connection( (options.server, options.port) )
     except socket.error:
-        logging("Couldn't connect to %(server)s on port %(port)d, is Carbon agent running?" %
-            { 'server': options.server, 'port': options.port, }, syslog.LOG_ERR)
+        logging(
+            "Couldn't connect to %(server)s on port %(port)d, is Carbon agent running?" %
+            {"server": options.server, "port": options.port},
+            syslog.LOG_ERR
+        )
         sys.exit(1)
 
     if options.tstamp > 0:
         handle_old_vpn_files(options.directory, options.tstamp)
     
     last_timestamp = 0
+    
     def on_loop(notifier):
         """
         Function called after each event loop to handle connexion errors.
@@ -135,23 +163,33 @@ if __name__ == '__main__':
         if last_timestamp > 0:
             sock.close()
             try:
-                sock = socket.create_connection( (options.server, options.port) )
+                sock = socket.create_connection((options.server, options.port))
             except socket.error:
-                logging("Couldn't connect to %(server)s on port %(port)d, is Carbon agent running?" %
-                    { 'server': options.server, 'port': options.port, }, syslog.LOG_ERR)
+                logging(
+                    "Couldn't connect to %(server)s on port %(port)d, is Carbon agent running?" %
+                    {"server": options.server, "port": options.port},
+                    syslog.LOG_ERR
+                )
             else:
                 handle_old_vpn_files(options.directory, last_timestamp)
                 last_timestamp = 0
 
     # https://github.com/seb-m/pyinotify/blob/master/python2/examples/daemon.py
-    logging("Starting to watch %s directory..." % (options.directory, ))
+    logging("Starting to watch %s directory..." % (options.directory,))
     wm = pyinotify.WatchManager()
     handler = VPNAcctHandler()
     notifier = pyinotify.Notifier(wm, handler)
-    wm.add_watch(options.directory, pyinotify.IN_CLOSE_WRITE|pyinotify.IN_MOVED_TO)
+    wm.add_watch(
+        options.directory,
+        pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO
+    )
 
     try:
-        notifier.loop(daemonize=True, callback=on_loop, pid_file='/var/run/pyinotify.pid', stdout='/var/log/pyinotify.log')
+        notifier.loop(
+            daemonize=True,
+            callback=on_loop,
+            pid_file="/var/run/pyinotify.pid",
+            stdout="/var/log/pyinotify.log"
+        )
     except pyinotify.NotifierError, err:
         print >> sys.stderr, err
-

+ 1 - 1
hardware_provisioning/__init__.py

@@ -1 +1 @@
-default_app_config = 'hardware_provisioning.app.HardwareProvisioningConfig'
+default_app_config = "hardware_provisioning.app.HardwareProvisioningConfig"

+ 78 - 56
hardware_provisioning/admin.py

@@ -19,11 +19,10 @@ admin.site.register(ItemType)
 
 class OwnerFilter(admin.SimpleListFilter):
     title = "Propriétaire"
-    parameter_name = 'owner'
+    parameter_name = "owner"
 
     def lookups(self, request, model_admin):
-        owners = [
-            (i.pk, i) for i in User.objects.filter(items__isnull=False)]
+        owners = [(i.pk, i) for i in User.objects.filter(items__isnull=False)]
 
         return [(None, "L'association")] + owners
 
@@ -36,21 +35,21 @@ class OwnerFilter(admin.SimpleListFilter):
 
 class AvailabilityFilter(admin.SimpleListFilter):
     title = "Disponibilité"
-    parameter_name = 'availability'
+    parameter_name = "availability"
 
     def lookups(self, request, model_admin):
         return [
-            ('available', 'Disponible'),
-            ('borrowed', 'Emprunté'),
-            ('deployed', 'Déployé'),
+            ("available", "Disponible"),
+            ("borrowed", "Emprunté"),
+            ("deployed", "Déployé"),
         ]
 
     def queryset(self, request, queryset):
-        if self.value() == 'available':
+        if self.value() == "available":
             return queryset.available()
-        elif self.value() == 'borrowed':
+        elif self.value() == "borrowed":
             return queryset.borrowed()
-        elif self.value() == 'deployed':
+        elif self.value() == "deployed":
             return queryset.deployed()
         else:
             return queryset
@@ -59,60 +58,67 @@ class AvailabilityFilter(admin.SimpleListFilter):
 @admin.register(Item)
 class ItemAdmin(admin.ModelAdmin):
     list_display = (
-        'designation', 'mac_address', 'serial', 'owner',
-        'buy_date', 'deployed', 'is_available', 'storage')
+        "designation",
+        "mac_address",
+        "serial",
+        "owner",
+        "buy_date",
+        "deployed",
+        "is_available",
+        "storage"
+    )
     list_filter = (
-        AvailabilityFilter, 'type', 'storage',
-        'buy_date', OwnerFilter)
+        AvailabilityFilter, "type", "storage", "buy_date", OwnerFilter
+    )
     search_fields = (
-        'designation', 'mac_address', 'serial',
-        'owner__email', 'owner__nickname',
-        'owner__first_name', 'owner__last_name')
+        "designation",
+        "mac_address",
+        "serial",
+        "owner__email",
+        "owner__nickname",
+        "owner__first_name",
+        "owner__last_name",
+    )
     save_as = True
-    actions = ['give_back']
+    actions = ["give_back"]
 
-    form = autocomplete_light.modelform_factory(Loan, fields='__all__')
+    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()
-    give_back.short_description = 'Rendre le matériel'
+
+    give_back.short_description = "Rendre le matériel"
 
 
 class StatusFilter(admin.SimpleListFilter):
-    title = 'Statut'
-    parameter_name = 'status'
+    title = "Statut"
+    parameter_name = "status"
 
     def lookups(self, request, model_admin):
-        return [
-            ('all', 'Tout'),
-            (None, 'En cours'),
-            ('finished', 'Passés'),
-        ]
+        return [("all", "Tout"), (None, "En cours"), ("finished", "Passés")]
 
     def choices(self, cl):
         for lookup, title in self.lookup_choices:
             yield {
-                'selected': self.value() == lookup,
-                'query_string': cl.get_query_string({
-                    self.parameter_name: lookup,
-                }, []),
-                'display': title,
+                "selected": self.value() == lookup,
+                "query_string": cl.get_query_string({self.parameter_name: lookup}, []),
+                "display": title,
             }
 
     def queryset(self, request, queryset):
         v = self.value()
-        if v in (None, 'running'):
+        if v in (None, "running"):
             return queryset.running()
-        elif v == 'finished':
+        elif v == "finished":
             return queryset.finished()
         else:
             return queryset
 
 
 class BorrowerFilter(admin.SimpleListFilter):
-    title = 'Adhérent emprunteur'
-    parameter_name = 'user'
+    title = "Adhérent emprunteur"
+    parameter_name = "user"
 
     def lookups(self, request, model_admin):
         users = set()
@@ -131,55 +137,69 @@ 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()
+        return obj.designation + " " + obj.get_mac_and_serial()
+
 
 @admin.register(Loan)
 class LoanAdmin(admin.ModelAdmin):
-    list_display = ('item', 'get_mac_and_serial', 'user', 'loan_date', 'loan_date_end')
-    list_filter = (StatusFilter, BorrowerFilter, 'item__designation')
+    list_display = ("item", "get_mac_and_serial", "user", "loan_date", "loan_date_end")
+    list_filter = (StatusFilter, BorrowerFilter, "item__designation")
     search_fields = (
-        'item__designation',
-        'user__nickname', 'user__username',
-        'user__first_name', 'user__last_name', )
-    actions = ['end_loan']
+        "item__designation",
+        "user__nickname",
+        "user__username",
+        "user__first_name",
+        "user__last_name",
+    )
+    actions = ["end_loan"]
 
     def end_loan(self, request, queryset):
-        queryset.filter(loan_date_end=None).update(
-            loan_date_end=datetime.now())
-    end_loan.short_description = 'Mettre fin au prêt'
+        queryset.filter(loan_date_end=None).update(loan_date_end=datetime.now())
+
+    end_loan.short_description = "Mettre fin au prêt"
 
-    form = autocomplete_light.modelform_factory(Loan, fields='__all__')
+    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()
+        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)
+            return super(LoanAdmin, self).formfield_for_foreignkey(
+                db_field, request, **kwargs
+            )
 
 
 @admin.register(Storage)
 class StorageAdmin(admin.ModelAdmin):
-    list_display = ('name', 'truncated_notes', 'items_count')
+    list_display = ("name", "truncated_notes", "items_count")
 
     def truncated_notes(self, obj):
         if len(obj.notes) > 50:
-            return '{}…'.format(obj.notes[:50])
+            return "{}…".format(obj.notes[:50])
         else:
             return obj.notes
-    truncated_notes.short_description = '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')
+    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')
+        return qs.order_by("-loan_date_end")
 
     def has_add_permission(self, request, obj=None):
         return False
@@ -187,8 +207,10 @@ class LoanInline(admin.TabularInline):
     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)

+ 3 - 3
hardware_provisioning/app.py

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

+ 4 - 6
hardware_provisioning/fields.py

@@ -7,14 +7,12 @@ from django.utils.translation import ugettext_lazy as _
 from django.forms import fields
 from django.db import models
 
-MAC_RE = r'^([0-9a-fA-F]{2}([:-]?|$)){6}$'
+MAC_RE = r"^([0-9a-fA-F]{2}([:-]?|$)){6}$"
 mac_re = re.compile(MAC_RE)
 
 
 class MACAddressFormField(fields.RegexField):
-    default_error_messages = {
-        'invalid': _(u'Enter a valid MAC address.'),
-    }
+    default_error_messages = {"invalid": _("Enter a valid MAC address.")}
 
     def __init__(self, *args, **kwargs):
         super(MACAddressFormField, self).__init__(mac_re, *args, **kwargs)
@@ -24,14 +22,14 @@ class MACAddressField(models.Field):
     empty_strings_allowed = False
 
     def __init__(self, *args, **kwargs):
-        kwargs['max_length'] = 17
+        kwargs["max_length"] = 17
         super(MACAddressField, self).__init__(*args, **kwargs)
 
     def get_internal_type(self):
         return "CharField"
 
     def formfield(self, **kwargs):
-        defaults = {'form_class': MACAddressFormField}
+        defaults = {"form_class": MACAddressFormField}
         defaults.update(kwargs)
         return super(MACAddressField, self).formfield(**defaults)
 

+ 14 - 14
hardware_provisioning/forms.py

@@ -13,31 +13,33 @@ User = get_user_model()
 
 class LoanDeclareForm(forms.Form):
     loan_date_end = forms.DateField(
-        label='Date de retour prévue',
+        label="Date de retour prévue",
         required=False,
         validators=[validate_future_date],
-        input_formats=['%d/%m/%Y'],
-        help_text='laisser vide si non planifié',
-        widget=forms.TextInput(
-            attrs={'type': 'date', 'placeholder': 'JJ/MM/AAAA'}))
+        input_formats=["%d/%m/%Y"],
+        help_text="laisser vide si non planifié",
+        widget=forms.TextInput(attrs={"type": "date", "placeholder": "JJ/MM/AAAA"}),
+    )
 
 
 class LoanReturnForm(forms.Form):
     storage = forms.ModelChoiceField(
-        label='Dans quel lieu de stockage ai-je remis le matériel ?',
+        label="Dans quel lieu de stockage ai-je remis le matériel ?",
         required=False,
-        queryset=Storage.objects.all(), empty_label='Je ne sais pas')
+        queryset=Storage.objects.all(),
+        empty_label="Je ne sais pas",
+    )
 
 
 class LoanTransferForm(forms.Form):
     target_user = forms.CharField(
         max_length=100,
-        label='Adhérent',
-        help_text='email, pseudonyme ou numéro de l\'adhérent',
+        label="Adhérent",
+        help_text="email, pseudonyme ou numéro de l'adhérent",
     )
 
     def clean_target_user(self):
-        value = self.cleaned_data['target_user']
+        value = self.cleaned_data["target_user"]
         result = User.objects.filter(
             Q(email__iexact=value)
             | Q(pk__iexact=value)
@@ -45,10 +47,8 @@ class LoanTransferForm(forms.Form):
             | Q(username__iexact=value)
         )
         if result.count() > 1:
-            raise ValidationError(
-                "La recherche retourne plus d'un adhérent")
+            raise ValidationError("La recherche retourne plus d'un adhérent")
         elif result.count() < 1:
-            raise ValidationError(
-                "Aucun adhérent ne correspond à cette recherche")
+            raise ValidationError("Aucun adhérent ne correspond à cette recherche")
 
         return result.first()

+ 71 - 59
hardware_provisioning/models.py

@@ -10,23 +10,22 @@ from .fields import MACAddressField
 
 
 class ItemType(models.Model):
-    name = models.CharField(max_length=100, verbose_name='nom')
+    name = models.CharField(max_length=100, verbose_name="nom")
 
     def __unicode__(self):
         return self.name
 
     class Meta:
-        verbose_name = 'type d’objet'
-        verbose_name_plural = 'types d’objet'
+        verbose_name = "type d’objet"
+        verbose_name_plural = "types d’objet"
 
 
 class ItemQuerySet(models.QuerySet):
     def _get_borrowed_pks(self):
-        return Loan.objects.running().values_list('item', flat=True)
+        return Loan.objects.running().values_list("item", flat=True)
 
     def available(self):
-        return self.exclude(
-            pk__in=self._get_borrowed_pks()).exclude(deployed=True)
+        return self.exclude(pk__in=self._get_borrowed_pks()).exclude(deployed=True)
 
     def borrowed(self):
         return self.filter(pk__in=self._get_borrowed_pks())
@@ -37,39 +36,52 @@ class ItemQuerySet(models.QuerySet):
     def unavailable(self):
         """ deployed or borrowed
         """
-        return self.filter(
-            Q(pk__in=self._get_borrowed_pks()) |
-            Q(deployed=True))
+        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',
-                             related_name='items')
-    designation = models.CharField(max_length=100, verbose_name='désignation')
+    type = models.ForeignKey(
+        ItemType, verbose_name="type de matériel", related_name="items"
+    )
+    designation = models.CharField(max_length=100, verbose_name="désignation")
     storage = models.ForeignKey(
-        'Storage', related_name='items',
-        verbose_name='Lieu de stockage',
-        null=True, blank=True,
-        help_text='Laisser vide si inconnu')
+        "Storage",
+        related_name="items",
+        verbose_name="Lieu de stockage",
+        null=True,
+        blank=True,
+        help_text="Laisser vide si inconnu",
+    )
     mac_address = MACAddressField(
-        verbose_name='adresse MAC',
-        blank=True, null=True, unique=True,
-        help_text="préférable au n° de série si possible")
+        verbose_name="adresse MAC",
+        blank=True,
+        null=True,
+        unique=True,
+        help_text="préférable au n° de série si possible",
+    )
     serial = models.CharField(
-        verbose_name='N° de série',
-        max_length=250, blank=True, null=True, unique=True,
-        help_text='ou toute autre référence unique')
-    buy_date = models.DateField(verbose_name='date d’achat' , blank=True , null=True)
+        verbose_name="N° de série",
+        max_length=250,
+        blank=True,
+        null=True,
+        unique=True,
+        help_text="ou toute autre référence unique",
+    )
+    buy_date = models.DateField(verbose_name="date d’achat", blank=True, null=True)
     owner = models.ForeignKey(
         settings.AUTH_USER_MODEL,
-        verbose_name='Propriétaire',
-        related_name='items',
-        null=True, blank=True,
-        help_text="dans le cas de matériel n'appartenant pas à l'association")
-    deployed = models.BooleanField(verbose_name='déployé', default=False,
-                                   help_text='Cocher si le matériel est en production')
-    comment = models.TextField(verbose_name='commentaire', blank=True,
-                               null=True)
+        verbose_name="Propriétaire",
+        related_name="items",
+        null=True,
+        blank=True,
+        help_text="dans le cas de matériel n'appartenant pas à l'association",
+    )
+    deployed = models.BooleanField(
+        verbose_name="déployé",
+        default=False,
+        help_text="Cocher si le matériel est en production",
+    )
+    comment = models.TextField(verbose_name="commentaire", blank=True, null=True)
 
     objects = ItemQuerySet().as_manager()
 
@@ -99,8 +111,9 @@ class Item(models.Model):
         or if the item is deployed, returns False (else True).
         """
         return (not self.deployed) and (not self.loans.running().exists())
+
     is_available.boolean = True
-    is_available.short_description = 'disponible'
+    is_available.short_description = "disponible"
 
     def get_mac_and_serial(self):
         mac = self.mac_address
@@ -108,26 +121,24 @@ class Item(models.Model):
         if mac and serial:
             return "{} / {}".format(mac, serial)
         else:
-            return mac or serial or ''
+            return mac or serial or ""
 
     class Meta:
-        verbose_name = 'objet'
-        ordering = ['designation', 'mac_address', 'serial']
+        verbose_name = "objet"
+        ordering = ["designation", "mac_address", "serial"]
 
     def give_back(self, storage=None):
         self.storage = storage
         self.save()
-        self.loans.running().update(
-            loan_date_end=timezone.now())
+        self.loans.running().update(loan_date_end=timezone.now())
 
 
 class LoanQuerySet(models.QuerySet):
-
     @staticmethod
     def _running_filter():
-        return (
-            models.Q(loan_date_end__gt=timezone.now()) |
-            models.Q(loan_date_end__isnull=True))
+        return models.Q(loan_date_end__gt=timezone.now()) | models.Q(
+            loan_date_end__isnull=True
+        )
 
     def running(self):
         return self.filter(self._running_filter())
@@ -137,17 +148,18 @@ class LoanQuerySet(models.QuerySet):
 
 
 class Loan(models.Model):
-    item = models.ForeignKey(Item, verbose_name='objet', related_name='loans')
-    user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name='membre',
-                             related_name='loans')
-    loan_date = models.DateTimeField(verbose_name='date de prêt')
-    loan_date_end = models.DateTimeField(verbose_name='date de fin de prêt',
-                                     null=True, blank=True)
+    item = models.ForeignKey(Item, verbose_name="objet", related_name="loans")
+    user = models.ForeignKey(
+        settings.AUTH_USER_MODEL, verbose_name="membre", related_name="loans"
+    )
+    loan_date = models.DateTimeField(verbose_name="date de prêt")
+    loan_date_end = models.DateTimeField(
+        verbose_name="date de fin de prêt", null=True, blank=True
+    )
     notes = models.TextField(null=True, blank=True)
 
     def __unicode__(self):
-        return 'prêt de {item} à {user}'.format(
-            item=self.item, user=self.user)
+        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()
@@ -159,29 +171,29 @@ class Loan(models.Model):
 
     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 ?'
+    is_running.short_description = "En cours ?"
 
     class Meta:
-        verbose_name = 'prêt d’objet'
-        verbose_name_plural = 'prêts d’objets'
+        verbose_name = "prêt d’objet"
+        verbose_name_plural = "prêts d’objets"
 
     objects = LoanQuerySet().as_manager()
 
 
 class Storage(models.Model):
-    name = models.CharField(max_length=100, verbose_name='nom')
-    notes = models.TextField(
-        blank=True,
-        help_text='Lisible par tous les adhérents')
+    name = models.CharField(max_length=100, verbose_name="nom")
+    notes = models.TextField(blank=True, help_text="Lisible par tous les adhérents")
 
     def __unicode__(self):
         return self.name
 
     def items_count(self):
         return self.items.count()
-    items_count.short_description = 'Nb. items stockés'
+
+    items_count.short_description = "Nb. items stockés"
 
     class Meta:
-        verbose_name = 'lieu de stockage'
-        verbose_name_plural = 'lieux de stockage'
+        verbose_name = "lieu de stockage"
+        verbose_name_plural = "lieux de stockage"

+ 14 - 16
hardware_provisioning/tests.py

@@ -9,23 +9,20 @@ from .models import Item, ItemType, Loan
 
 def localize(naive_dt):
     if not timezone.is_naive(naive_dt):
-        raise ValueError('Expecting a naive datetime')
+        raise ValueError("Expecting a naive datetime")
     else:
         return timezone.make_aware(naive_dt, timezone.get_current_timezone())
 
 
 class HardwareModelsFactoryMixin:
     def get_item_type(self, **kwargs):
-        params = {'name': 'Foos'}
+        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 = {"type": self.get_item_type(), "designation": "Test item"}
         params.update(**kwargs)
         item, _ = Item.objects.get_or_create(**params)
         return item
@@ -33,14 +30,14 @@ class HardwareModelsFactoryMixin:
 
 class HardwareLoaningTestCase(HardwareModelsFactoryMixin, TestCase):
     def setUp(self):
-        self.member = Member.objects.create(username='jdoe')
+        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 = Loan.objects.create(
-            item=self.item, user=self.member,
-            loan_date=loan_start_date)
+            item=self.item, user=self.member, loan_date=loan_start_date
+        )
 
         self.assertEqual(Loan.objects.running().count(), 1)
         self.assertEqual(Loan.objects.finished().count(), 0)
@@ -51,12 +48,11 @@ class HardwareLoaningTestCase(HardwareModelsFactoryMixin, TestCase):
 
 class ItemTestCase(HardwareModelsFactoryMixin, TestCase):
     def setUp(self):
-        self.member = Member.objects.create(username='jdoe')
+        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')
+        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)
@@ -65,8 +61,10 @@ class ItemTestCase(HardwareModelsFactoryMixin, TestCase):
         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)))
+            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)

+ 6 - 6
hardware_provisioning/urls.py

@@ -6,10 +6,10 @@ from . import views
 
 
 urlpatterns = [
-    url(r'^$', views.loan_list, name='loan-list'),
-    url(r'^items/list$', views.item_list, name='item-list'),
-    url(r'^items/(?P<pk>[0-9]+)/borrow$', views.item_borrow, name='item-borrow'),
-    url(r'^(?P<pk>[0-9]+)/return$', views.loan_return, name='loan-return'),
-    url(r'^(?P<pk>[0-9]+)/transfer$', views.loan_transfer, name='loan-transfer'),
-    url(r'^(?P<pk>[0-9]+)$', views.loan_detail, name='loan-detail'),
+    url(r"^$", views.loan_list, name="loan-list"),
+    url(r"^items/list$", views.item_list, name="item-list"),
+    url(r"^items/(?P<pk>[0-9]+)/borrow$", views.item_borrow, name="item-borrow"),
+    url(r"^(?P<pk>[0-9]+)/return$", views.loan_return, name="loan-return"),
+    url(r"^(?P<pk>[0-9]+)/transfer$", views.loan_transfer, name="loan-transfer"),
+    url(r"^(?P<pk>[0-9]+)$", views.loan_detail, name="loan-detail"),
 ]

+ 1 - 2
hardware_provisioning/validators.py

@@ -8,5 +8,4 @@ from django.utils import timezone
 
 def validate_future_date(value):
     if value <= timezone.now():
-        raise ValidationError(
-            'La date de retour doit être dans le futur')
+        raise ValidationError("La date de retour doit être dans le futur")

+ 42 - 45
hardware_provisioning/views.py

@@ -16,13 +16,11 @@ from .models import Item, Loan
 
 @login_required
 def item_list(request):
-    items = Item.objects.all().order_by('storage', 'type', 'designation')
+    items = Item.objects.all().order_by("storage", "type", "designation")
 
     # FIXME: suboptimal
     items = [i for i in items.filter() if i.is_available()]
-    return render(request, 'hardware_provisioning/item_list.html', {
-        'items': items,
-    })
+    return render(request, "hardware_provisioning/item_list.html", {"items": items})
 
 
 @login_required
@@ -30,32 +28,33 @@ def item_borrow(request, pk):
     item = get_object_or_404(Item, pk=pk)
 
     if not item.is_available():
-        return HttpResponseForbidden('Item non disponible')
+        return HttpResponseForbidden("Item non disponible")
 
-    if request.method == 'POST':
+    if request.method == "POST":
         form = LoanDeclareForm(request.POST)
         if form.is_valid():
             loan = Loan.objects.create(
                 item=item,
                 loan_date=timezone.now(),
-                loan_date_end=form.cleaned_data['loan_date_end'],
+                loan_date_end=form.cleaned_data["loan_date_end"],
                 user=request.user,
             )
             messages.success(
-                request, "Emprunt de {} ({}) enregistré".format(
-                    item.designation, item.type))
+                request,
+                "Emprunt de {} ({}) enregistré".format(item.designation, item.type),
+            )
             if not loan.loan_date_end:
                 messages.warning(
                     request,
-                    "N'oubliez pas de notifier le retour de l'objet le temps venu")
-            return redirect(reverse('hardware_provisioning:loan-list'))
+                    "N'oubliez pas de notifier le retour de l'objet le temps venu",
+                )
+            return redirect(reverse("hardware_provisioning:loan-list"))
     else:
         form = LoanDeclareForm()
 
-    return render(request, 'hardware_provisioning/item_borrow.html', {
-        'item': item,
-        'form': form,
-    })
+    return render(
+        request, "hardware_provisioning/item_borrow.html", {"item": item, "form": form}
+    )
 
 
 @login_required
@@ -63,24 +62,22 @@ def loan_return(request, pk):
     loan = get_object_or_404(Loan, pk=pk)
 
     if not loan.user_can_close(request.user):
-        return HttpResponseForbidden('Non autorisé')
+        return HttpResponseForbidden("Non autorisé")
 
-    if request.method == 'POST':
+    if request.method == "POST":
         form = LoanReturnForm(request.POST)
         if form.is_valid():
             messages.success(
-                request,
-                'Le matériel {} a été marqué comme rendu'.format(
-                    loan.item))
-            loan.item.give_back(form.cleaned_data['storage'])
-            return redirect(reverse('hardware_provisioning:loan-list'))
+                request, "Le matériel {} a été marqué comme rendu".format(loan.item)
+            )
+            loan.item.give_back(form.cleaned_data["storage"])
+            return redirect(reverse("hardware_provisioning:loan-list"))
     else:
         form = LoanReturnForm()
 
-    return render(request, 'hardware_provisioning/return.html', {
-        'loan': loan,
-        'form': form,
-    })
+    return render(
+        request, "hardware_provisioning/return.html", {"loan": loan, "form": form}
+    )
 
 
 @login_required
@@ -92,45 +89,45 @@ def loan_transfer(request, pk):
     if not old_loan.user_can_close(request.user):
         return HttpResponseForbidden()
 
-    if request.method == 'POST':
+    if request.method == "POST":
         form = LoanTransferForm(request.POST)
         if form.is_valid():
             old_loan.item.give_back()
             Loan.objects.create(
-                user=form.cleaned_data['target_user'],
+                user=form.cleaned_data["target_user"],
                 loan_date=timezone.now(),
-                item=old_loan.item)
+                item=old_loan.item,
+            )
             messages.success(
                 request,
-                "Le matériel {} a été transféré à l'adhérent \"{}\"".format(
-                    old_loan.item,
-                    form.data['target_user']))
-            return redirect(reverse('hardware_provisioning:loan-list'))
+                'Le matériel {} a été transféré à l\'adhérent "{}"'.format(
+                    old_loan.item, form.data["target_user"]
+                ),
+            )
+            return redirect(reverse("hardware_provisioning:loan-list"))
 
     else:
         form = LoanTransferForm()
 
-    return render(request, 'hardware_provisioning/transfer.html', {
-        'form': form,
-        'loan': old_loan,
-    })
+    return render(
+        request, "hardware_provisioning/transfer.html", {"form": form, "loan": old_loan}
+    )
 
 
 @login_required
 def loan_list(request):
-    view = 'old' if 'old' in request.GET else ''
+    view = "old" if "old" in request.GET else ""
 
-    if view == 'old':
-        loans = request.user.loans.finished().order_by('-loan_date_end')
+    if view == "old":
+        loans = request.user.loans.finished().order_by("-loan_date_end")
     else:
         loans = request.user.loans.running()
 
-    return render(request, 'hardware_provisioning/list.html', {
-        'loans': loans,
-        'view': view,
-    })
+    return render(
+        request, "hardware_provisioning/list.html", {"loans": loans, "view": view}
+    )
 
 
 @login_required
 def loan_detail(request, pk):
-    return render(request, 'hardware_provisioning/detail.html', {})
+    return render(request, "hardware_provisioning/detail.html", {})

+ 1 - 0
simple_dsl/admin.py

@@ -21,4 +21,5 @@ class SimpleDSLAdmin(ConfigurationAdminFormMixin, PolymorphicChildModelAdmin):
     # in the admin.
     view_on_site = False
 
+
 admin.site.register(SimpleDSL, SimpleDSLAdmin)

+ 10 - 7
simple_dsl/models.py

@@ -13,19 +13,22 @@ class SimpleDSL(Configuration):
     and IP addresses of subscribers, which may be useful for "white label"
     DSL reselling.
     """
+
     class Meta:
-        verbose_name = 'DSL line'
+        verbose_name = "DSL line"
         # If Django's default pluralisation is not satisfactory
-        #verbose_name_plural = 'very many DSL lines'
+        # verbose_name_plural = 'very many DSL lines'
 
     # URL namespace associated to this configuration type, to build URLs
     # in various view.  Should also be defined in urls.py.  Here, we don't
     # define any view, so there's no need for an URL namespace.
-    #url_namespace = "dsl"
-    phone_number = models.CharField(max_length=20,
-                                    verbose_name='phone number',
-                                    help_text="Phone number associated to the DSL line")
-    
+    # url_namespace = "dsl"
+    phone_number = models.CharField(
+        max_length=20,
+        verbose_name="phone number",
+        help_text="Phone number associated to the DSL line",
+    )
+
     def __unicode__(self):
         return self.phone_number
 

+ 1 - 1
vpn/__init__.py

@@ -1 +1 @@
-default_app_config = 'vpn.apps.VPNConfig'
+default_app_config = "vpn.apps.VPNConfig"

+ 34 - 13
vpn/admin.py

@@ -13,28 +13,43 @@ from .models import VPNConfiguration
 class VPNConfigurationInline(admin.StackedInline):
     model = VPNConfiguration
     # fk_name = 'offersubscription'
-    exclude = ('password',)
-    readonly_fields = ['configuration_ptr', 'login']
+    exclude = ("password",)
+    readonly_fields = ["configuration_ptr", "login"]
 
 
 class VPNConfigurationAdmin(ConfigurationAdminFormMixin, PolymorphicChildModelAdmin):
     base_model = VPNConfiguration
-    list_display = ('offersubscription', 'activated', 'login',
-                    'ipv4_endpoint', 'ipv6_endpoint', 'comment')
-    list_filter = ('activated',)
-    search_fields = ('login', 'comment',
-                     # TODO: searching on member directly doesn't work
-                     'offersubscription__member__first_name',
-                     'offersubscription__member__last_name',
-                     'offersubscription__member__email')
-    actions = (delete_selected, "generate_endpoints", "generate_endpoints_v4",
-               "generate_endpoints_v6", "activate", "deactivate")
+    list_display = (
+        "offersubscription",
+        "activated",
+        "login",
+        "ipv4_endpoint",
+        "ipv6_endpoint",
+        "comment",
+    )
+    list_filter = ("activated",)
+    search_fields = (
+        "login",
+        "comment",
+        # TODO: searching on member directly doesn't work
+        "offersubscription__member__first_name",
+        "offersubscription__member__last_name",
+        "offersubscription__member__email",
+    )
+    actions = (
+        delete_selected,
+        "generate_endpoints",
+        "generate_endpoints_v4",
+        "generate_endpoints_v6",
+        "activate",
+        "deactivate",
+    )
     exclude = ("password",)
     inline = VPNConfigurationInline
 
     def get_readonly_fields(self, request, obj=None):
         if obj:
-            return ['login',]
+            return ["login"]
         else:
             return []
 
@@ -54,10 +69,12 @@ class VPNConfigurationAdmin(ConfigurationAdminFormMixin, PolymorphicChildModelAd
 
     def activate(self, request, queryset):
         self.set_activation(request, queryset, True)
+
     activate.short_description = "Activer les VPN sélectionnés"
 
     def deactivate(self, request, queryset):
         self.set_activation(request, queryset, False)
+
     deactivate.short_description = "Désactiver les VPN sélectionnés"
 
     def generate_endpoints_generic(self, request, queryset, v4=True, v6=True):
@@ -72,14 +89,18 @@ class VPNConfigurationAdmin(ConfigurationAdminFormMixin, PolymorphicChildModelAd
 
     def generate_endpoints(self, request, queryset):
         self.generate_endpoints_generic(request, queryset)
+
     generate_endpoints.short_description = "Attribuer des adresses IPv4 et IPv6"
 
     def generate_endpoints_v4(self, request, queryset):
         self.generate_endpoints_generic(request, queryset, v6=False)
+
     generate_endpoints_v4.short_description = "Attribuer des adresses IPv4"
 
     def generate_endpoints_v6(self, request, queryset):
         self.generate_endpoints_generic(request, queryset, v4=False)
+
     generate_endpoints_v6.short_description = "Attribuer des adresses IPv6"
 
+
 admin.site.register(VPNConfiguration, VPNConfigurationAdmin)

+ 2 - 2
vpn/apps.py

@@ -8,7 +8,7 @@ from . import urls
 
 
 class VPNConfig(AppConfig, coin.apps.AppURLs):
-    name = 'vpn'
+    name = "vpn"
     verbose_name = "Gestion d'accès VPN"
 
-    exported_urlpatterns = [('vpn', urls.urlpatterns)]
+    exported_urlpatterns = [("vpn", urls.urlpatterns)]

+ 53 - 32
vpn/models.py

@@ -12,6 +12,7 @@ from ldapdb.models.fields import CharField, ListField
 from coin.mixins import CoinLdapSyncMixin
 from coin.offers.models import OfferSubscription
 from coin.configuration.models import Configuration
+
 # from coin.offers.backends import ValidateBackendType
 from coin import utils
 from coin import validation
@@ -24,25 +25,36 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
     #     'offers.OfferSubscription',
     #     related_name=backend_name,
     #     validators=[ValidateBackendType(backend_name)])
-    activated = models.BooleanField(default=False, verbose_name='activé')
-    login = models.CharField(max_length=50, unique=True, blank=True,
-                             verbose_name="identifiant",
-                             help_text="Laisser vide pour une génération automatique")
-    password = models.CharField(max_length=256, verbose_name="mot de passe",
-                                blank=True, null=True)
-    ipv4_endpoint = InetAddressField(validators=[validation.validate_v4],
-                                     verbose_name="IPv4", blank=True, null=True,
-                                     help_text="Adresse IPv4 utilisée par "
-                                     "défaut sur le VPN")
-    ipv6_endpoint = InetAddressField(validators=[validation.validate_v6],
-                                     verbose_name="IPv6", blank=True, null=True,
-                                     help_text="Adresse IPv6 utilisée par "
-                                     "défaut sur le VPN")
+    activated = models.BooleanField(default=False, verbose_name="activé")
+    login = models.CharField(
+        max_length=50,
+        unique=True,
+        blank=True,
+        verbose_name="identifiant",
+        help_text="Laisser vide pour une génération automatique",
+    )
+    password = models.CharField(
+        max_length=256, verbose_name="mot de passe", blank=True, null=True
+    )
+    ipv4_endpoint = InetAddressField(
+        validators=[validation.validate_v4],
+        verbose_name="IPv4",
+        blank=True,
+        null=True,
+        help_text="Adresse IPv4 utilisée par " "défaut sur le VPN",
+    )
+    ipv6_endpoint = InetAddressField(
+        validators=[validation.validate_v6],
+        verbose_name="IPv6",
+        blank=True,
+        null=True,
+        help_text="Adresse IPv6 utilisée par " "défaut sur le VPN",
+    )
 
     objects = NetManager()
 
     def get_absolute_url(self):
-        return reverse('vpn:details', args=[str(self.pk)])
+        return reverse("vpn:details", args=[str(self.pk)])
 
     # This method is part of the general configuration interface.
     def subnet_event(self):
@@ -63,7 +75,7 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
             config = LdapVPNConfig.objects.get(pk=self.login)
         config.login = config.sn = self.login
         config.password = self.password
-        config.active = 'yes' if self.activated else 'no'
+        config.active = "yes" if self.activated else "no"
         config.ipv4_endpoint = utils.str_or_none(self.ipv4_endpoint)
         config.ipv6_endpoint = utils.str_or_none(self.ipv6_endpoint)
         config.ranges_v4 = [str(s) for s in self.get_subnets(4)]
@@ -110,7 +122,9 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
         """
         error = "L'IP {} n'est pas dans un réseau attribué."
         subnets = self.ip_subnet.all()
-        is_faulty = lambda endpoint : endpoint and not any([endpoint in subnet.inet for subnet in subnets])
+        is_faulty = lambda endpoint: endpoint and not any(
+            [endpoint in subnet.inet for subnet in subnets]
+        )
         if is_faulty(self.ipv4_endpoint):
             if delete:
                 self.ipv4_endpoint = None
@@ -128,7 +142,9 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
         # separator.
         if not self.login:
             username = self.offersubscription.member.username
-            vpns = VPNConfiguration.objects.filter(offersubscription__member__username=username)
+            vpns = VPNConfiguration.objects.filter(
+                offersubscription__member__username=username
+            )
             # This is the list of existing VPN logins for this user.
             logins = [vpn.login for vpn in vpns]
             # 100 VPNs ought to be enough for anybody.
@@ -148,26 +164,31 @@ class VPNConfiguration(CoinLdapSyncMixin, Configuration):
         self.check_endpoints()
 
     def __unicode__(self):
-        return 'VPN ' + self.login
+        return "VPN " + self.login
 
     class Meta:
-        verbose_name = 'VPN'
-        verbose_name_plural = 'VPN'
+        verbose_name = "VPN"
+        verbose_name_plural = "VPN"
 
 
 class LdapVPNConfig(ldapdb.models.Model):
     base_dn = settings.VPN_CONF_BASE_DN
-    object_classes = [b'person', b'organizationalPerson', b'inetOrgPerson',
-                      b'top', b'radiusprofile']
-
-    login = CharField(db_column=b'cn', primary_key=True, max_length=255)
-    sn = CharField(db_column=b'sn', max_length=255)
-    password = CharField(db_column=b'userPassword', max_length=255)
-    active = CharField(db_column=b'dialupAccess', max_length=3)
-    ipv4_endpoint = CharField(db_column=b'radiusFramedIPAddress', max_length=16)
-    ipv6_endpoint = CharField(db_column=b'postalAddress', max_length=40)
-    ranges_v4 = ListField(db_column=b'radiusFramedRoute')
-    ranges_v6 = ListField(db_column=b'registeredAddress')
+    object_classes = [
+        b"person",
+        b"organizationalPerson",
+        b"inetOrgPerson",
+        b"top",
+        b"radiusprofile",
+    ]
+
+    login = CharField(db_column=b"cn", primary_key=True, max_length=255)
+    sn = CharField(db_column=b"sn", max_length=255)
+    password = CharField(db_column=b"userPassword", max_length=255)
+    active = CharField(db_column=b"dialupAccess", max_length=3)
+    ipv4_endpoint = CharField(db_column=b"radiusFramedIPAddress", max_length=16)
+    ipv6_endpoint = CharField(db_column=b"postalAddress", max_length=40)
+    ranges_v4 = ListField(db_column=b"radiusFramedRoute")
+    ranges_v6 = ListField(db_column=b"registeredAddress")
 
     def __unicode__(self):
         return self.login

+ 8 - 7
vpn/tests.py

@@ -13,12 +13,13 @@ from coin.members.tests import MemberTestsUtils
 
 from .models import VPNConfiguration
 
-USING_POSTGRES = (settings.DATABASES['default']['ENGINE']
-                  ==
-                  'django.db.backends.postgresql_psycopg2')
+USING_POSTGRES = (
+    settings.DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql_psycopg2"
+)
+
 
 class VPNTestCase(TestCase):
-    fixtures = ['example_pools.json', 'offers.json']
+    fixtures = ["example_pools.json", "offers.json"]
 
     def setUp(self):
         self.v6_pool = IPPool.objects.get(default_subnetsize=56)
@@ -27,9 +28,9 @@ class VPNTestCase(TestCase):
 
         # Create a member.
         cn = MemberTestsUtils.get_random_username()
-        self.member = Member.objects.create(first_name=u"Toto",
-                                            last_name=u"L'artichaut",
-                                            username=cn)
+        self.member = Member.objects.create(
+            first_name="Toto", last_name="L'artichaut", username=cn
+        )
 
         # Create a new VPN with subnets.
         # We need Django to call clean() so that magic happens.

+ 11 - 5
vpn/urls.py

@@ -6,11 +6,17 @@ from django.conf.urls import patterns, url
 from .views import VPNView, VPNGeneratePasswordView, get_graph
 
 urlpatterns = patterns(
-    '',
+    "",
     # This is part of the generic configuration interface (the "name" is
     # the same as the "backend_name" of the model).
-    url(r'^(?P<pk>\d+)$', VPNView.as_view(template_name="vpn/vpn.html"), name="details"),
-    url(r'^password/(?P<pk>\d+)$', VPNGeneratePasswordView.as_view(template_name="vpn/fragments/password.html"), name="generate_password"),
-    url(r'^graph/(?P<vpn_id>[0-9]+)/(?P<period>[a-z]+)$', get_graph, name="get_graph"),
-    url(r'^graph/(?P<vpn_id>[0-9]+)$', get_graph, name="get_graph"),
+    url(
+        r"^(?P<pk>\d+)$", VPNView.as_view(template_name="vpn/vpn.html"), name="details"
+    ),
+    url(
+        r"^password/(?P<pk>\d+)$",
+        VPNGeneratePasswordView.as_view(template_name="vpn/fragments/password.html"),
+        name="generate_password",
+    ),
+    url(r"^graph/(?P<vpn_id>[0-9]+)/(?P<period>[a-z]+)$", get_graph, name="get_graph"),
+    url(r"^graph/(?P<vpn_id>[0-9]+)$", get_graph, name="get_graph"),
 )

+ 29 - 15
vpn/views.py

@@ -20,7 +20,7 @@ from .models import VPNConfiguration
 
 class VPNView(SuccessMessageMixin, UpdateView):
     model = VPNConfiguration
-    fields = ['ipv4_endpoint', 'ipv6_endpoint', 'comment']
+    fields = ["ipv4_endpoint", "ipv6_endpoint", "comment"]
     success_message = "Configuration enregistrée avec succès !"
 
     @method_decorator(login_required)
@@ -31,8 +31,11 @@ class VPNView(SuccessMessageMixin, UpdateView):
         if self.request.user.is_superuser:
             return get_object_or_404(VPNConfiguration, pk=self.kwargs.get("pk"))
         # For normal users, ensure the VPN belongs to them.
-        return get_object_or_404(VPNConfiguration, pk=self.kwargs.get("pk"),
-                                 offersubscription__member=self.request.user)
+        return get_object_or_404(
+            VPNConfiguration,
+            pk=self.kwargs.get("pk"),
+            offersubscription__member=self.request.user,
+        )
 
 
 class VPNGeneratePasswordView(VPNView):
@@ -48,7 +51,7 @@ class VPNGeneratePasswordView(VPNView):
         # This will hash the password automatically
         self.object.full_clean()
         self.object.save()
-        context['password'] = password
+        context["password"] = password
         return context
 
 
@@ -60,20 +63,31 @@ def get_graph(request, vpn_id, period="daily"):
         vpn = get_object_or_404(VPNConfiguration, pk=vpn_id)
     else:
         # For normal users, ensure the VPN belongs to them
-        vpn = get_object_or_404(VPNConfiguration, pk=vpn_id,
-                                offersubscription__member=request.user)
+        vpn = get_object_or_404(
+            VPNConfiguration, pk=vpn_id, offersubscription__member=request.user
+        )
 
-    time_periods = { 'hourly': '-1hour', 'daily': '-24hours', 'weekly': '-8days', 'monthly': '-32days', 'yearly': '-13months', }
+    time_periods = {
+        "hourly": "-1hour",
+        "daily": "-24hours",
+        "weekly": "-8days",
+        "monthly": "-32days",
+        "yearly": "-13months",
+    }
     if period not in time_periods:
-        period = 'daily'
+        period = "daily"
 
-    graph_url = os.path.join(settings.GRAPHITE_SERVER,
-                "render/?width=586&height=308&from=%(period)s&" \
-                "target=alias%%28scaleToSeconds%%28vpn1.%(login)s.downrxbytes%%2C1%%29%%2C%%20%%22Download%%22%%29&" \
-                "target=alias%%28scaleToSeconds%%28vpn1.%(login)s.uptxbytes%%2C1%%29%%2C%%20%%22Upload%%22%%29&" \
-                "title=VPN%%20Usage%%20%(login)s" % \
-                    { 'period': time_periods[period], 'login': vpn.login })
+    graph_url = os.path.join(
+        settings.GRAPHITE_SERVER,
+        "render/?width=586&height=308&from=%(period)s&"
+        "target=alias%%28scaleToSeconds%%28vpn1.%(login)s.downrxbytes%%2C1%%29%%2C%%20%%22Download%%22%%29&"
+        "target=alias%%28scaleToSeconds%%28vpn1.%(login)s.uptxbytes%%2C1%%29%%2C%%20%%22Upload%%22%%29&"
+        "title=VPN%%20Usage%%20%(login)s"
+        % {"period": time_periods[period], "login": vpn.login},
+    )
     try:
-        return StreamingHttpResponse(urllib2.urlopen(graph_url), content_type="image/png")
+        return StreamingHttpResponse(
+            urllib2.urlopen(graph_url), content_type="image/png"
+        )
     except urllib2.URLError:
         return HttpResponseServerError()