1
0

55 Commits e4c95221db ... 919b9be8fa

Auteur SHA1 Message Date
  Jocelyn Delalande 919b9be8fa Switch default dev env to vagrant VM il y a 8 ans
  Jocelyn Delalande c9a6a9ae8f Add a vagrant VM running a PostgreSQL database il y a 8 ans
  Jocelyn Delalande bf73e2c16c Fix conflicting migrations il y a 8 ans
  Jocelyn Delalande 088b36ea29 Add forgotten migration il y a 8 ans
  jocelyn 43d3f0156f Merge branch 'fix-108-membershipfree-mandatory-date-fields' of daimrod/coin into master il y a 8 ans
  daimrod ada390f6e0 Do not crash if there is no start_date (fix #108) il y a 8 ans
  jocelyn 63b7492dfe Merge branch 'optional_buy_date' of dam35/coin into master il y a 8 ans
  jocelyn c72ca8534e Merge branch 'fix_95_close_invoice_if_completely_paid' of arlo/coin into master il y a 8 ans
  Jérémie Pierson c58fa7a808 Close invoice only if amount_paid >= amount il y a 8 ans
  dam 73f168f54c Make optional Item.buy_date for hardware provisioning il y a 8 ans
  jocelyn aec872293b Merge branch 'add_invoice_tax' of dam35/coin into master il y a 8 ans
  dam 321751aa44 Add total without tax in invoice pdf il y a 8 ans
  dam 283954a070 Add computation of total without tax amount_before_tax() il y a 8 ans
  dam 74c92f75c8 Add test for Invoice.amount() il y a 8 ans
  daimrod 4eee8a55e7 Merge branch 'feat_105_add_comments_field' of arlo/coin into master il y a 8 ans
  Jérémie Pierson 7d906a9191 OfferSubscription: Add "Comments" field (#105) il y a 8 ans
  Jocelyn Delalande 87a63df940 Add forgotten migration il y a 8 ans
  jocelyn abbaf69afe Merge branch 'isp-multiple-technologies' of opi/coin into master il y a 8 ans
  Jocelyn Delalande 0cedaec515 Add some logging to console il y a 8 ans
  opi 49f1700bab Enable multiple technologies in ISP's coveredAreas. il y a 8 ans
  jocelyn a058d014eb Merge branch 'fix-98-no-price-in-label' of daimrod/coin into master il y a 8 ans
  jocelyn f3f41dbc7b Merge branch 'feat-inline-reference' of daimrod/coin into master il y a 8 ans
  jocelyn 0277bfedd2 Merge branch 'django-1.8' of FFDN/coin into master il y a 8 ans
  Jocelyn Delalande 2691ea8d09 Fix django-netfields usage and version il y a 8 ans
  Jocelyn Delalande 70336e8ed9 Make install guide copy-pastable il y a 8 ans
  Jocelyn Delalande 69c123a389 Remove buggy and obsolete network validation il y a 8 ans
  Jocelyn Delalande 636bceb6b3 Fix crash on /members/contact when no chatroom specified il y a 8 ans
  daimrod 25c58a388e Fix #98 : Do not show the price in the label of billings il y a 8 ans
  daimrod 8d67b4efaf Display the reference of the offers in the Member view il y a 8 ans
  Jocelyn Delalande 18135781db Validate the chatroom URL il y a 8 ans
  Jocelyn Delalande 6cd751c47a Upgrade to Django 1.8 il y a 8 ans
  Jocelyn Delalande b26a468f8e Add missing django-polymorphic migrations il y a 8 ans
  Jocelyn Delalande ddf8ebed9b Upgrade dependencies to be Django-1.8 compatible il y a 8 ans
  Jocelyn Delalande a5444e4157 Adapt admin configs to Django 1.8 il y a 8 ans
  Jocelyn Delalande 39a7670291 Fix vpn migrations with newer django/django-polymorphic il y a 8 ans
  Jocelyn Delalande ddc1c05670 Use default field length for EmailField il y a 8 ans
  Jocelyn Delalande 3da23145c9 Add forgotten (no-op) migration il y a 8 ans
  Jocelyn Delalande e7aff463b4 Fix whitespace errors il y a 8 ans
  Jocelyn Delalande 374cefc835 Fix model validation tests to rely on full_clean() il y a 8 ans
  Jocelyn Delalande ea8b18a4e4 Fix automatic call for membership without ISPInfo il y a 8 ans
  Jocelyn Delalande e613d3b88b Skip postgres-related tests if not using postgres il y a 8 ans
  Jocelyn Delalande c2e8244741 Offer pytest as testing tool il y a 8 ans
  Jocelyn Delalande 6a43379886 Add an alternate settings module dedicated to unit tests. il y a 8 ans
  Jocelyn Delalande 01caad30bd Change settings layout il y a 8 ans
  Jocelyn Delalande 8c513c8e5a Fix invoice number on sequence of previous month il y a 8 ans
  Jocelyn Delalande 4273911f8c Fix crash on invoice validation with PostgreSQL db il y a 8 ans
  jocelyn 0d68f6e891 Merge branch 'jd-legalize-invoices' of FFDN/coin into master il y a 8 ans
  Jocelyn Delalande ca5bd00506 Fix issue with duplicate blank fields il y a 8 ans
  Jocelyn Delande 0fb6bb0051 Set date to validation date il y a 8 ans
  Jocelyn Delande b8688ea4c6 Use a draft invoice number before validation il y a 8 ans
  Jocelyn Delande 2f3776cdb0 Use proper and legal sequential invoice numbers il y a 8 ans
  Jocelyn Delande 66343b9c95 Enforce that a validated bill has a pdf il y a 8 ans
  Jocelyn Delande 4e988ad62d Rename Invoice.is_pdf_exists() → Invoice.pdf_exists() il y a 8 ans
  jocelyn c03bb8a490 Merge branch 'serialunique' of dam35/coin into master il y a 8 ans
  dam b2033fde28 Add libfii-dev in README (need for weezyprint) il y a 8 ans
42 fichiers modifiés avec 1042 ajouts et 338 suppressions
  1. 2 0
      .gitignore
  2. 59 9
      README.md
  3. 53 0
      Vagrantfile
  4. 1 1
      coin/billing/admin.py
  5. 1 1
      coin/billing/migrations/0001_initial.py
  6. 21 0
      coin/billing/migrations/0004_auto_20161230_1803.py
  7. 107 18
      coin/billing/models.py
  8. 1 1
      coin/billing/templates/admin/billing/invoice/change_form.html
  9. 1 1
      coin/billing/templates/billing/invoice.html
  10. 16 4
      coin/billing/templates/billing/invoice_pdf.html
  11. 99 1
      coin/billing/tests.py
  12. 19 0
      coin/configuration/migrations/0004_auto_20161015_1837.py
  13. 1 0
      coin/isp_database/admin.py
  14. 21 0
      coin/isp_database/migrations/0011_auto_20170227_0029.py
  15. 21 0
      coin/isp_database/migrations/0011_auto_20170309_1247.py
  16. 21 0
      coin/isp_database/migrations/0012_auto_20170328_2257.py
  17. 15 0
      coin/isp_database/migrations/0013_merge.py
  18. 19 11
      coin/isp_database/models.py
  19. 23 0
      coin/isp_database/tests.py
  20. 2 2
      coin/members/admin.py
  21. 42 0
      coin/members/migrations/0013_auto_20161015_1837.py
  22. 3 2
      coin/members/models.py
  23. 1 1
      coin/members/templates/members/invoices.html
  24. 37 5
      coin/members/tests.py
  25. 2 1
      coin/offers/admin.py
  26. 19 0
      coin/offers/migrations/0007_offersubscription_comments.py
  27. 3 0
      coin/offers/models.py
  28. 2 2
      coin/resources/migrations/0001_initial.py
  29. 1 1
      coin/resources/migrations/0003_auto_20150203_1043.py
  30. 15 17
      coin/resources/models.py
  31. 1 251
      coin/settings.py
  32. 262 0
      coin/settings_base.py
  33. 11 0
      coin/settings_test.py
  34. 16 0
      coin/utils.py
  35. 8 0
      coin/validation.py
  36. 20 0
      hardware_provisioning/migrations/0013_auto_20161110_2246.py
  37. 19 0
      hardware_provisioning/migrations/0014_auto_20170422_1847.py
  38. 11 3
      hardware_provisioning/models.py
  39. 8 6
      requirements.txt
  40. 4 0
      setup.cfg
  41. 42 0
      vpn/migrations/0001_squashed_0002_remove_vpnconfiguration_comment.py
  42. 12 0
      vpn/tests.py

+ 2 - 0
.gitignore

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

+ 59 - 9
README.md

@@ -50,12 +50,12 @@ Install dependencies. On Debian, you will probably need the
 `python-dev`, `python-pip`, `libldap-dev`, `libpq-dev`, `libsasl2-dev`,
 and `libjpeg-dev` packages.
 
-    sudo apt-get install python-dev python-pip libldap2-dev libpq-dev libsasl2-dev libjpeg-dev libxml2-dev libxslt1-dev
+    sudo apt-get install python-dev python-pip libldap2-dev libpq-dev libsasl2-dev libjpeg-dev libxml2-dev libxslt1-dev libffi-dev
 
 You need a recent *pip* for the installation of dependencies to work. If you
 don't meet that requirement (Ubuntu trusty does not), run:
 
-    pip install pip>=1.5.6
+    pip install "pip>=1.5.6"
 
 In any case, you then need to install coin python dependencies:
 
@@ -79,18 +79,58 @@ See the end of this README for a reference of available configuration settings.
 Database
 --------
 
-At this point, you should setup your database. The default setting
-uses SQLite but some features will not be available, namely:
+At this point, you should setup your database. You have two options.
+
+### With PostgreSQL (for developpement), recomended
+
+The official database for coin is postgresql.
+
+To ease developpement, a postgresql virtual-machine recipe is provided
+through [vagrant](https://vagrantup.com).
+
+
+**Note: Vagrant is intended for developpement only and is totaly unsafe for a
+production setup**.
+
+Install requirements:
+
+    sudo apt install virtualbox vagrant
+
+Then, to boot and configure your dev VM:
+
+    vagrant up
+
+Default settings target that vagrant+postgreSQL setup, so, you don't have to
+change any setting.
+
+
+### With SQLite
+
+SQLite setup may be simpler, but some features will not be available, namely:
 
 - automatic allocation of IP subnets (needs proper subnet implementation in
   the database)
 - sending automated emails to remind of expiring membership fee
   (needs aggregation on date fields, see Django doc)
 
-If you want to use those features, you will need to setup a PostgreSQL
-database.
-
-For more information on the database setup, see https://www.illyse.org/projects/ils-si/wiki/Mise_en_place_environnement_de_dev
+To use sqlite instead of PostgreSQL, you have
+to [override local settings](#settings) with someting like:
+
+```python
+DATABASES = {
+    # Base de donnée du SI
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': 'coin.sqlite3',
+        'USER': '', # Not needed for SQLite
+        'PASSWORD': '', # Not needed for SQLite
+        'HOST': '',  # Empty for localhost through domain sockets
+        'PORT': '',  # Empty for default
+    },
+}
+```
+
+### For both PostgreSQL and SQLite
 
 The first time, you need to create the database, create a superuser, and
 import some base data to play with:
@@ -120,13 +160,23 @@ Running tests
 
 There is a set of unit tests you can run with :
 
-    ./manage.py test
+    DJANGO_SETTINGS_MODULE=coin.settings_test ./manage.py test
 
 LDAP-related tests are disabled by default.
 
 Setup LDAP parameters and activate LDAP in settings to make the LDAP tests
 run.
 
+### With pytest
+
+Setup:
+
+    pip install pytest-django
+
+Run:
+
+    pytest
+
 
 Available commands
 ==================

+ 53 - 0
Vagrantfile

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

+ 1 - 1
coin/billing/admin.py

@@ -78,7 +78,7 @@ class InvoiceAdmin(admin.ModelAdmin):
               ('member'),
               ('amount', 'amount_paid'),
               ('validated', 'pdf'))
-    readonly_fields = ('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):

+ 1 - 1
coin/billing/migrations/0001_initial.py

@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
             fields=[
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                 ('validated', models.BooleanField(default=False, verbose_name='valid\xe9e')),
-                ('number', models.CharField(default=coin.billing.models.next_invoice_number, unique=True, max_length=25, verbose_name='num\xe9ro')),
+                ('number', models.CharField(unique=True, max_length=25, verbose_name='num\xe9ro')),
                 ('status', models.CharField(default='open', max_length=50, verbose_name='statut', choices=[('open', 'A payer'), ('closed', 'Regl\xe9e'), ('trouble', 'Litige')])),
                 ('date', models.DateField(default=datetime.date.today, null=True, verbose_name='date')),
                 ('date_due', models.DateField(default=coin.utils.end_of_month, null=True, verbose_name="date d'\xe9ch\xe9ance de paiement")),

+ 21 - 0
coin/billing/migrations/0004_auto_20161230_1803.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import datetime
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0003_auto_20150221_2226'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoice',
+            name='date',
+            field=models.DateField(default=datetime.date.today, help_text='Cette date sera d\xe9finie \xe0 la date de validation dans la facture finale', null=True, verbose_name='date'),
+            preserve_default=True,
+        ),
+    ]

+ 107 - 18
coin/billing/models.py

@@ -5,26 +5,21 @@ import datetime
 import random
 import uuid
 import os
+import re
 from decimal import Decimal
 
-from django.db import models
+from django.db import models, transaction
 from django.db.models.signals import post_save
 from django.dispatch import receiver
+from django.utils.encoding import python_2_unicode_compatible
 
 from coin.offers.models import OfferSubscription
 from coin.members.models import Member
 from coin.html2pdf import render_as_pdf
 from coin.utils import private_files_storage, start_of_month, end_of_month, \
-                       disable_for_loaddata
+                       disable_for_loaddata, postgresql_regexp
 from coin.isp_database.context_processors import branding
 
-def next_invoice_number():
-    """Détermine un numéro de facture aléatoire"""
-    return '%s%02i-%i-%i' % (datetime.date.today().year,
-                             datetime.date.today().month,
-                             random.randrange(100, 999),
-                             random.randrange(100, 999))
-
 
 def invoice_pdf_filename(instance, filename):
     """Nom et chemin du fichier pdf à stocker pour les factures"""
@@ -33,6 +28,81 @@ def invoice_pdf_filename(instance, filename):
                                       instance.number,
                                       uuid.uuid4())
 
+@python_2_unicode_compatible
+class InvoiceNumber:
+    """ Logic and validation of invoice numbers
+
+    Defines invoice numbers serie in a way that is legal in france.
+
+    https://www.service-public.fr/professionnels-entreprises/vosdroits/F23208#fiche-item-3
+
+    Our format is YYYY-MM-XXXXXX
+    - YYYY the year of the bill
+    - 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})')
+
+    def __init__(self, date, index):
+        self.date = date
+        self.index = index
+
+    def get_next(self):
+        return InvoiceNumber(self.date, self.index + 1)
+
+    def __str__(self):
+        return '{:%Y-%m}-{:0>6}'.format(self.date, self.index)
+
+    @classmethod
+    def parse(cls, string):
+        m = cls.RE_INVOICE_NUMBER.match(string)
+        if not m:
+            raise ValueError('Not a valid invoice number: "{}"'.format(string))
+
+        return cls(
+            datetime.date(
+                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'):
+        """ Build queryset filter to be used to get the invoices from the
+        numbering sequence of a given date.
+
+        :param field_name: the invoice field name to filter on.
+
+        :type date: datetime
+        :rtype: dict
+        """
+
+        return {'{}__month'.format(field_name): date.month}
+
+
+class InvoiceQuerySet(models.QuerySet):
+    def get_next_invoice_number(self, date):
+        last_invoice_number_str = self._get_last_invoice_number(date)
+
+        if last_invoice_number_str is None:
+            # It's the first bill of the month
+            invoice_number = InvoiceNumber(date, 1)
+        else:
+            invoice_number = InvoiceNumber.parse(last_invoice_number_str).get_next()
+
+        return str(invoice_number)
+
+    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']
+
+    def with_valid_number(self):
+        """ Excludes previous numbering schemes or draft invoices
+        """
+        return self.filter(number__regex=postgresql_regexp(
+            InvoiceNumber.RE_INVOICE_NUMBER))
 
 class Invoice(models.Model):
 
@@ -46,14 +116,14 @@ class Invoice(models.Model):
                                     help_text='Once validated, a PDF is generated'
                                     ' and the invoice cannot be modified')
     number = models.CharField(max_length=25,
-                              default=next_invoice_number,
                               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')
+        default=datetime.date.today, null=True, verbose_name='date',
+        help_text='Cette date sera définie à la date de validation dans la facture finale')
     date_due = models.DateField(
         default=end_of_month,
         null=True,
@@ -67,6 +137,14 @@ class Invoice(models.Model):
                            null=True, blank=True,
                            verbose_name='PDF')
 
+    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.save()
+
     def amount(self):
         """
         Calcul le montant de la facture
@@ -78,6 +156,13 @@ class Invoice(models.Model):
         return total.quantize(Decimal('0.01'))
     amount.short_description = 'Montant'
 
+    def amount_before_tax(self):
+        total = Decimal('0.0')
+        for detail in self.details.all():
+            total += detail.amount
+        return total.quantize(Decimal('0.01'))
+    amount.short_description = 'Montant HT'
+
     def amount_paid(self):
         """
         Calcul le montant payé de la facture en fonction des éléments
@@ -111,17 +196,20 @@ class Invoice(models.Model):
         pdf_file = render_as_pdf('billing/invoice_pdf.html', context)
         self.pdf.save('%s.pdf' % self.number, pdf_file)
 
+    @transaction.atomic
     def validate(self):
         """
         Switch invoice to validate mode. This set to False the draft field
         and generate the pdf
         """
-        if not self.is_pdf_exists():
-            self.validated = True
-            self.save()
-            self.generate_pdf()
-
-    def is_pdf_exists(self):
+        self.date = datetime.date.today()
+        self.number = Invoice.objects.get_next_invoice_number(self.date)
+        self.validated = True
+        self.save()
+        self.generate_pdf()
+        assert self.pdf_exists()
+
+    def pdf_exists(self):
         return (self.validated
                 and bool(self.pdf)
                 and private_files_storage.exists(self.pdf.name))
@@ -136,6 +224,7 @@ class Invoice(models.Model):
     class Meta:
         verbose_name = 'facture'
 
+    objects = InvoiceQuerySet().as_manager()
 
 class InvoiceDetail(models.Model):
 
@@ -211,7 +300,7 @@ def set_invoice_as_paid_if_needed(sender, instance, **kwargs):
     Lorsqu'un paiement est enregistré, vérifie si la facture est alors
     complétement payée. Dans ce cas elle passe en réglée
     """
-    if (instance.invoice.amount_paid >= instance.invoice.amount and
+    if (instance.invoice.amount_paid() >= instance.invoice.amount() and
             instance.invoice.status == 'open'):
         instance.invoice.status = 'closed'
         instance.invoice.save()

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

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

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

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

+ 16 - 4
coin/billing/templates/billing/invoice_pdf.html

@@ -82,6 +82,7 @@
   .cell-label {width: 70%;}
   .cell-quantity {width: 5%;}
   .cell-amount {width: 10%;}
+  .cell-tax {width: 5%;}
   .cell-total {width: 15%;}
 
   /* details cell style */
@@ -91,8 +92,10 @@
   .cell-quantity {
     text-align: center;
   }
-  .cell--money {
+  .cell--money, 
+  .cell-tax {
     text-align: right;
+    white-space: nowrap;
   }
 
   .cell-label p + p {
@@ -158,7 +161,8 @@
       <tr>
         <th class="cell-label cell--empty"></th>
         <th class="cell-quantity">Quantité</th>
-        <th class="cell-amount cell--money">PU</th>
+        <th class="cell-amount cell--money">PU (HT)</th>
+        <th class="cell-label cell-tax">TVA</th>
         <th class="cell-total cell--money">Total</th>
       </tr>
     </thead>
@@ -170,7 +174,7 @@
           {{ detail.label }}
           {% if detail.offersubscription %}
             <br/>
-            <span class="subscription">{{ detail.offersubscription.offer }}
+            <span class="subscription">{{ detail.offersubscription.offer.name }}
             {% if detail.offersubscription.offer.reference %} ({{ detail.offersubscription.get_subscription_reference }}){% endif %}
             </span>
           {% endif %}
@@ -181,14 +185,22 @@
         </td>
         <td class="cell-quantity">{{ detail.quantity }}</td>
         <td class="cell-amount cell--money">{{ detail.amount }}€</td>
+        <td class="cell-tax">{{ detail.tax }}%</td>
         <td class="cell-total cell--money">{{ detail.total }}€</td>
       </tr>
       {% endfor %}
+      
       <tr>
         <td class="cell-result cell--empty"></td>
-        <td class="cell-result result-label" colspan="2">Total TTC</td>
+        <td class="result-label " colspan="3">Total HT</td>
+        <td class="cell--money ">{{ invoice.amount_before_tax }}€</td>
+      </tr>
+      <tr>
+        <td class="cell-result cell--empty"></td>
+        <td class="cell-result result-label" colspan="3">Total TTC</td>
         <td class="cell-result result-total cell--money">{{ invoice.amount }}€</td>
       </tr>
+       
     </tbody>
   </table>
 

+ 99 - 1
coin/billing/tests.py

@@ -6,9 +6,10 @@ from decimal import Decimal
 
 from django.conf import settings
 from django.test import TestCase, Client
+from freezegun import freeze_time
 from coin.members.tests import MemberTestsUtils
 from coin.members.models import Member, LdapUser
-from coin.billing.models import Invoice
+from coin.billing.models import Invoice, InvoiceQuerySet
 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
@@ -113,6 +114,62 @@ class BillingInvoiceCreationTests(TestCase):
                          datetime.date(2014, 4, 1))
         self.assertEqual(invoice_test_2.details.first().period_to,
                          datetime.date(2014, 5, 31))
+                         
+    def test_invoice_amount(self):
+        invoice = Invoice(member=self.member)
+        invoice.save()
+
+        invoice.details.create(label=self.offer.name,
+                               amount=100,
+                               offersubscription=self.subscription,
+                               period_from=datetime.date(2014, 1, 1),
+                               period_to=datetime.date(2014, 3, 31),
+                               tax=0)
+
+        invoice.details.create(label=self.offer.name,
+                               amount=10,
+                               offersubscription=self.subscription,
+                               period_from=datetime.date(2014, 6, 1),
+                               period_to=datetime.date(2014, 8, 31),
+                               tax=10)
+        
+        self.assertEqual(invoice.amount(), 111)
+
+    def test_invoice_partial_payment(self):
+        invoice = Invoice(member=self.member)
+        invoice.save()
+
+        invoice.details.create(label=self.offer.name,
+                               amount=100,
+                               offersubscription=self.subscription,
+                               period_from=datetime.date(2014, 1, 1),
+                               period_to=datetime.date(2014, 3, 31),
+                               tax=0)
+        self.assertEqual(invoice.status, 'open')
+        invoice.payments.create(payment_mean='cash', amount=10)
+        self.assertEqual(invoice.status, 'open')
+        invoice.payments.create(payment_mean='cash', amount=90)
+        self.assertEqual(invoice.status, 'closed')
+
+    def test_invoice_amount_before_tax(self):
+        invoice = Invoice(member=self.member)
+        invoice.save()
+
+        invoice.details.create(label=self.offer.name,
+                               amount=100,
+                               offersubscription=self.subscription,
+                               period_from=datetime.date(2014, 1, 1),
+                               period_to=datetime.date(2014, 3, 31),
+                               tax=0)
+
+        invoice.details.create(label=self.offer.name,
+                               amount=10,
+                               offersubscription=self.subscription,
+                               period_from=datetime.date(2014, 6, 1),
+                               period_to=datetime.date(2014, 8, 31),
+                               tax=10)
+        
+        self.assertEqual(invoice.amount_before_tax(), 110)
 
     def test_non_billable_offer_isnt_charged(self):
         """
@@ -222,3 +279,44 @@ class BillingTests(TestCase):
 
         member_a.delete()
         member_b.delete()
+
+
+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')
+
+    @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')
+
+    @freeze_time('2016-01-01')
+    def test_number_workflow(self):
+        iv = Invoice.objects.create()
+        self.assertEqual(iv.number, 'DRAFT-1')
+        iv.validate()
+        self.assertRegexpMatches(iv.number, r'2016-01-000001$')
+
+    @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.validate()
+        self.assertEqual(
+            Invoice.objects.get_next_invoice_number(datetime.date(2016,1,1)),
+            '2016-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))
+
+        with freeze_time('2017-01-01'):
+            bill.validate()
+            self.assertEqual(bill.date, datetime.date(2017, 1, 1))
+            self.assertEqual(bill.number, '2017-01-000001')

+ 19 - 0
coin/configuration/migrations/0004_auto_20161015_1837.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('configuration', '0003_configuration_comment'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='configuration',
+            name='polymorphic_ctype',
+            field=models.ForeignKey(related_name='polymorphic_configuration.configuration_set+', editable=False, to='contenttypes.ContentType', null=True),
+        ),
+    ]

+ 1 - 0
coin/isp_database/admin.py

@@ -12,6 +12,7 @@ class ISPAdminForm(ModelForm):
 
     class Meta:
         model = ISPInfo
+        exclude = []
 
     phone_number = FRPhoneNumberField(required=False,
                                       help_text='Main contact phone number')

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

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

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

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import multiselectfield.db.fields
+from coin.isp_database.models import TECHNOLOGIES
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('isp_database', '0010_ispinfo_phone_number'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='coveredarea',
+            name='technologies',
+            field=multiselectfield.db.fields.MultiSelectField(max_length=42, choices=TECHNOLOGIES),
+        ),
+    ]

+ 21 - 0
coin/isp_database/migrations/0012_auto_20170328_2257.py

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

+ 15 - 0
coin/isp_database/migrations/0013_merge.py

@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('isp_database', '0012_auto_20170328_2257'),
+        ('isp_database', '0011_auto_20170227_0029'),
+    ]
+
+    operations = [
+    ]

+ 19 - 11
coin/isp_database/models.py

@@ -8,9 +8,12 @@ from django.core.exceptions import ValidationError
 from localflavor.generic.models import IBANField, BICField
 from localflavor.fr.models import FRSIRETField
 
+from multiselectfield import MultiSelectField
+
 from coin.members.models import count_active_members
 from coin.offers.models import count_active_subscriptions
 from coin import utils
+from coin.validation import chatroom_url_validator
 
 # API version, see http://db.ffdn.org/format
 API_VERSION = 0.1
@@ -61,9 +64,8 @@ class ISPInfo(SingleInstanceMixin, models.Model):
                               help_text="HTTP(S) URL of the ISP's logo")
     website = models.URLField(blank=True,
                               help_text='URL to the official website')
-    email = models.EmailField(max_length=254,
-                              help_text="Contact email address")
-    mainMailingList = models.EmailField(max_length=254, blank=True,
+    email = models.EmailField(help_text="Contact email address")
+    mainMailingList = models.EmailField(blank=True,
                                         verbose_name="main mailing list",
                                         help_text="Main public mailing-list")
     phone_number = models.CharField(max_length=25, blank=True,
@@ -97,11 +99,11 @@ class ISPInfo(SingleInstanceMixin, models.Model):
 
     # field outside of db-ffdn format:
     administrative_email = models.EmailField(
-        max_length=254, blank=True, verbose_name="contact administratif",
+        blank=True, verbose_name="contact administratif",
         help_text='Adresse email pour les contacts administratifs (ex: bureau)')
 
     support_email = models.EmailField(
-        max_length=254, blank=True, verbose_name="contact de support",
+        blank=True, verbose_name="contact de support",
         help_text="Adresse email pour les demandes de support technique")
 
     lists_url = models.URLField(
@@ -115,8 +117,13 @@ class ISPInfo(SingleInstanceMixin, models.Model):
 
     @property
     def main_chat_verbose(self):
-        m = utils.re_chat_url.match(self.chatroom_set.first().url)
-        return '{channel} sur {server}'.format(**(m.groupdict()))
+        first_chatroom = self.chatroom_set.first()
+        if first_chatroom:
+            m = utils.re_chat_url.match(first_chatroom.url)
+            if m:
+                return '{channel} sur {server}'.format(**(m.groupdict()))
+
+        return None
 
     def get_absolute_url(self):
         return '/isp.json'
@@ -191,21 +198,22 @@ class RegisteredOffice(models.Model):
 
 
 class ChatRoom(models.Model):
-    url = models.CharField(verbose_name="URL", max_length=256)
+    url = models.CharField(
+        verbose_name="URL", max_length=256, validators=[chatroom_url_validator])
     isp = models.ForeignKey(ISPInfo)
 
 
 class CoveredArea(models.Model):
     name = models.CharField(max_length=512)
-    # TODO: we must allow multiple values
-    technologies = models.CharField(choices=TECHNOLOGIES, max_length=16)
+
+    technologies = MultiSelectField(choices=TECHNOLOGIES, max_length=42)
     # TODO: find a geojson library
     #area =
     isp = models.ForeignKey(ISPInfo)
 
     def to_dict(self):
         return {"name": self.name,
-                "technologies": [self.technologies]}
+                "technologies": self.technologies}
 
 
 class BankInfo(models.Model):

+ 23 - 0
coin/isp_database/tests.py

@@ -1,8 +1,11 @@
+from django.contrib.auth.models import UserManager
 from django.test import TestCase
 
 # Create your tests here.
 
+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):
@@ -16,3 +19,23 @@ class TestPrettifiers(TestCase):
         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')
+
+    def test_chat_view(self):
+        isp = ISPInfo.objects.create(name='test', email='foo@example.com', )
+
+        # Without chatroom
+        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')
+
+        response = self.client.get('/members/contact/')
+        self.assertEqual(response.status_code, 200)

+ 2 - 2
coin/members/admin.py

@@ -35,8 +35,8 @@ class MembershipFeeInline(admin.TabularInline):
 class OfferSubscriptionInline(admin.TabularInline):
     model = OfferSubscription
     extra = 0
-    readonly_fields = ('subscription_date', 'resign_date', 'commitment',
-                       'offer', 'show_change_link')
+    readonly_fields = ('get_subscription_reference', 'subscription_date', 'resign_date',
+                       'commitment', 'offer', 'show_change_link')
 
     # FIXME: Workaround en attendant la migration vers Django >=1.8
     # À remplacer par InlineModelAdmin.show_change_link = True

+ 42 - 0
coin/members/migrations/0013_auto_20161015_1837.py

@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.core.validators
+import django.contrib.auth.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('members', '0012_member_date_last_call_for_membership_fees_email'),
+    ]
+
+    operations = [
+        migrations.AlterModelManagers(
+            name='member',
+            managers=[
+                ('objects', django.contrib.auth.models.UserManager()),
+            ],
+        ),
+        migrations.AlterField(
+            model_name='member',
+            name='email',
+            field=models.EmailField(unique=True, max_length=254, verbose_name='email address'),
+        ),
+        migrations.AlterField(
+            model_name='member',
+            name='groups',
+            field=models.ManyToManyField(related_query_name='user', related_name='user_set', to='auth.Group', blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', verbose_name='groups'),
+        ),
+        migrations.AlterField(
+            model_name='member',
+            name='last_login',
+            field=models.DateTimeField(null=True, verbose_name='last login', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='member',
+            name='username',
+            field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, max_length=30, validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.', 'invalid')], help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True, verbose_name='username'),
+        ),
+    ]

+ 3 - 2
coin/members/models.py

@@ -281,7 +281,8 @@ class Member(CoinLdapSyncMixin, AbstractUser):
 
         isp_info = ISPInfo.objects.first()
         kwargs = {}
-        if isp_info.administrative_email:
+        # Il peut ne pas y avir d'ISPInfo, ou bien pas d'administrative_email
+        if isp_info and isp_info.administrative_email:
             kwargs['from_email'] = isp_info.administrative_email
 
         # Si le dernier courriel de relance a été envoyé il y a moins de trois
@@ -427,7 +428,7 @@ class MembershipFee(models.Model):
                                     verbose_name='date du paiement')
 
     def clean(self):
-        if self.end_date is None:
+        if self.start_date is not None and self.end_date is None:
             self.end_date = self.start_date + datetime.timedelta(364)
 
     def __unicode__(self):

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

@@ -20,7 +20,7 @@
             <td>{{ invoice.date }}</td>
             <td>{{ invoice.amount }}</td>
             <td{% if invoice.amount_remaining_to_pay > 0 %} class="unpaid"{% endif %}>{{ invoice.amount_remaining_to_pay }}</td>
-            <td>{% if invoice.is_pdf_exists %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}"><i class="fa fa-file-pdf-o"></i> PDF</a>{% endif %}</td>
+            <td>{% if invoice.validated %}<a href="{% url 'billing:invoice_pdf' id=invoice.number %}"><i class="fa fa-file-pdf-o"></i> PDF</a>{% endif %}</td>
         </tr>
         {% empty %}
         <tr class="placeholder"><td colspan="6">Aucune facture.</td></tr>

+ 37 - 5
coin/members/tests.py

@@ -14,8 +14,10 @@ from django.conf import settings
 from django.test import TestCase, Client, override_settings
 from django.contrib.auth.models import User
 from django.core import mail, management
+from django.core.exceptions import ValidationError
 
 from coin.members.models import Member, MembershipFee, LdapUser
+from coin.validation import chatroom_url_validator
 
 
 @unittest.skipIf(not settings.LDAP_ACTIVATE, "LDAP disabled")
@@ -339,7 +341,7 @@ class MemberTests(TestCase):
 
         # Créé une cotisation passée
         membershipfee = MembershipFee(member=member, amount=20,
-                                      start_date=date.today() + 
+                                      start_date=date.today() +
                                       relativedelta(years=-1),
                                       end_date=date.today() + relativedelta(days=-10))
         membershipfee.save()
@@ -349,7 +351,7 @@ class MemberTests(TestCase):
 
         # Créé une cotisation actuelle
         membershipfee = MembershipFee(member=member, amount=20,
-                                      start_date=date.today() + 
+                                      start_date=date.today() +
                                       relativedelta(days=-10),
                                       end_date=date.today() + relativedelta(days=+10))
         membershipfee.save()
@@ -363,10 +365,14 @@ class MemberTests(TestCase):
         (prenom, nom) ou pseudo ou nom d'organization
         """
         member = Member(username='blop')
-        self.assertRaises(Exception, member.save)
+        with self.assertRaises(Exception):
+            member.full_clean()
+            member.save()
+
+        with self.assertRaises(Exception):
+            member.full_clean()
+            member.save()
 
-        member = Member()
-        self.assertRaises(Exception, member.save)
 
 
 class MemberAdminTests(TestCase):
@@ -496,3 +502,29 @@ class MemberTestsUtils(object):
         Renvoi une clé aléatoire pour un utilisateur LDAP
         """
         return 'coin_test_' + os.urandom(8).encode('hex')
+
+
+class TestValidators(TestCase):
+    def test_valid_chatroom(self):
+        chatroom_url_validator('irc://irc.example.com/#chan')
+        with self.assertRaises(ValidationError):
+            chatroom_url_validator('http://#faimaison@irc.geeknode.org')
+
+
+class MembershipFeeTests(TestCase):
+    def test_mandatory_start_date(self):
+        member = Member(first_name='foo', last_name='foo', password='foo', email='foo')
+        member.save()
+
+        # If there is no start_date clean_fields() should raise an
+        # error but not clean().
+        membershipfee = MembershipFee(member=member)
+        self.assertRaises(ValidationError, membershipfee.clean_fields)
+        self.assertIsNone(membershipfee.clean())
+
+        # If there is a start_date, everything is fine.
+        membershipfee = MembershipFee(member=member, start_date=date.today())
+        self.assertIsNone(membershipfee.clean_fields())
+        self.assertIsNone(membershipfee.clean())
+
+        member.delete()

+ 2 - 1
coin/offers/admin.py

@@ -42,7 +42,8 @@ class OfferSubscriptionAdmin(admin.ModelAdmin):
                 'offer',
                 'subscription_date',
                 'commitment',
-                'resign_date'
+                'resign_date',
+                'comments'
              )
     form = autocomplete_light.modelform_factory(OfferSubscription, fields='__all__')
 

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

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

+ 3 - 0
coin/offers/models.py

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

+ 2 - 2
coin/resources/migrations/0001_initial.py

@@ -20,7 +20,7 @@ class Migration(migrations.Migration):
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
                 ('name', models.CharField(help_text="Nom du pool d'IP", max_length=255, verbose_name='nom')),
                 ('default_subnetsize', models.PositiveSmallIntegerField(help_text='Taille par d\xe9faut du sous-r\xe9seau \xe0 allouer aux abonn\xe9s dans ce pool', verbose_name='taille de sous-r\xe9seau par d\xe9faut', validators=[django.core.validators.MaxValueValidator(64)])),
-                ('inet', netfields.fields.CidrAddressField(help_text="Bloc d'adresses IP du pool", max_length=43, verbose_name='r\xe9seau', validators=[coin.resources.models.validate_subnet])),
+                ('inet', netfields.fields.CidrAddressField(help_text="Bloc d'adresses IP du pool", max_length=43, verbose_name='r\xe9seau')),
             ],
             options={
                 'verbose_name': "pool d'IP",
@@ -32,7 +32,7 @@ class Migration(migrations.Migration):
             name='IPSubnet',
             fields=[
                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
-                ('inet', netfields.fields.CidrAddressField(blank=True, help_text='Laisser vide pour allouer automatiquement', max_length=43, verbose_name='sous-r\xe9seau', validators=[coin.resources.models.validate_subnet])),
+                ('inet', netfields.fields.CidrAddressField(blank=True, help_text='Laisser vide pour allouer automatiquement', max_length=43, verbose_name='sous-r\xe9seau')),
                 ('delegate_reverse_dns', models.BooleanField(default=False, help_text='D\xe9l\xe9guer la r\xe9solution DNS inverse de ce sous-r\xe9seau \xe0 un ou plusieurs serveurs de noms', verbose_name='d\xe9l\xe9guer le reverse DNS')),
                 ('configuration', models.ForeignKey(related_name='ip_subnet', verbose_name='configuration', to='configuration.Configuration')),
                 ('ip_pool', models.ForeignKey(verbose_name="pool d'IP", to='resources.IPPool')),

+ 1 - 1
coin/resources/migrations/0003_auto_20150203_1043.py

@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
         migrations.AlterField(
             model_name='ipsubnet',
             name='inet',
-            field=netfields.fields.CidrAddressField(validators=[coin.resources.models.validate_subnet], max_length=43, blank=True, help_text='Laisser vide pour allouer automatiquement', unique=True, verbose_name='sous-r\xe9seau'),
+            field=netfields.fields.CidrAddressField(max_length=43, blank=True, help_text='Laisser vide pour allouer automatiquement', unique=True, verbose_name='sous-r\xe9seau'),
             preserve_default=True,
         ),
     ]

+ 15 - 17
coin/resources/models.py

@@ -4,18 +4,8 @@ from __future__ import unicode_literals
 from django.db import models
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator
-from django.db.models import Q
 from netfields import CidrAddressField, NetManager
-from netaddr import IPNetwork, IPSet
-
-
-def validate_subnet(cidr):
-    """Checks that a CIDR object is indeed a subnet, i.e. the host bits are
-    all set to zero."""
-    if not isinstance(cidr, IPNetwork):
-        raise ValidationError("Erreur, objet IPNetwork attendu.")
-    if cidr.ip != cidr.network:
-        raise ValidationError("{} n'est pas un sous-réseau valide, voulez-vous dire {} ?".format(cidr, cidr.cidr))
+from netaddr import IPSet
 
 
 class IPPool(models.Model):
@@ -27,8 +17,7 @@ class IPPool(models.Model):
                                                           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(validators=[validate_subnet],
-                            verbose_name='réseau',
+    inet = CidrAddressField(verbose_name='réseau',
                             help_text="Bloc d'adresses IP du pool")
     objects = NetManager()
 
@@ -55,7 +44,7 @@ class IPPool(models.Model):
 
 
 class IPSubnet(models.Model):
-    inet = CidrAddressField(blank=True, validators=[validate_subnet],
+    inet = CidrAddressField(blank=True,
                             unique=True, verbose_name="sous-réseau",
                             help_text="Laisser vide pour allouer automatiquement")
     objects = NetManager()
@@ -95,9 +84,18 @@ class IPSubnet(models.Model):
         if not self.inet in self.ip_pool.inet:
             raise ValidationError("Le sous-réseau doit être inclus dans le bloc d'IP.")
         # Check that we don't conflict with existing subnets.
-        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)
-        if conflicting:
+
+        # The optimal request would be the following commented request, but
+        # django-netfields 0.4.x seems buggy with Q-expressions. For now use
+        # 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) |
+        #                                               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)
+        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))
 
     def validate_reverse_dns(self):

+ 1 - 251
coin/settings.py

@@ -1,257 +1,7 @@
 # -*- coding: utf-8 -*-
-from __future__ import unicode_literals
 
-import os
-import ldap
+from settings_base import *
 
-# Django settings for coin project.
-
-# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
-BASE_DIR = os.path.dirname(os.path.dirname(__file__))
-
-PROJECT_PATH = os.path.abspath(os.path.dirname(__file__))
-DEBUG = TEMPLATE_DEBUG = True
-
-ADMINS = (
-    # ('Your Name', 'your_email@example.com'),
-)
-
-MANAGERS = ADMINS
-
-DATABASES = {
-    # Base de donnée du SI
-    'default': {
-        'ENGINE': 'django.db.backends.sqlite3',
-        'NAME': 'coin.sqlite3',
-        'USER': '', # Not needed for SQLite
-        'PASSWORD': '', # Not needed for SQLite
-        'HOST': '',  # Empty for localhost through domain sockets
-        'PORT': '',  # Empty for default
-    },
-}
-
-# Hosts/domain names that are valid for this site; required if DEBUG is False
-# See https://docs.djangoproject.com/en/1.7/ref/settings/#allowed-hosts
-ALLOWED_HOSTS = []
-
-# Local time zone for this installation. Choices can be found here:
-# 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'
-
-# Language code for this installation. All choices can be found here:
-# http://www.i18nguy.com/unicode/language-identifiers.html
-LANGUAGE_CODE = 'fr-fr'
-
-SITE_ID = 1
-
-# If you set this to False, Django will make some optimizations so as not
-# to load the internationalization machinery.
-USE_I18N = True
-
-# If you set this to False, Django will not format dates, numbers and
-# calendars according to the current locale.
-USE_L10N = True
-
-# If you set this to False, Django will not use timezone-aware datetimes.
-USE_TZ = True
-
-# Default URL for login and 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/')
-
-# 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/'
-
-# 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/')
-
-# URL prefix for static files.
-# Example: "http://example.com/static/", "http://static.example.com/"
-STATIC_URL = '/static/'
-
-# Additional locations of static files
-STATICFILES_DIRS = (
-    # Put strings here, like "/home/html/static" or "C:/www/django/static".
-    # Always use forward slashes, even on Windows.
-    # Don't forget to use absolute paths, not relative paths.
-)
-
-# 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',
-)
-
-# 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/')
-
-# 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'
-
-# Make this unique, and don't share it with anybody.
-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',
-)
-
-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',
-    # Uncomment the next line for simple clickjacking protection:
-    # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
-)
-
-ROOT_URLCONF = 'coin.urls'
-
-# Python dotted path to the WSGI application used by Django's runserver.
-WSGI_APPLICATION = 'coin.wsgi.application'
-
-TEMPLATE_DIRS = (
-    # Only absolute paths, always forward slashes
-    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',
-    # Uncomment the next line to enable the admin:
-    'django.contrib.admin',
-    # Uncomment the next line to enable admin documentation:
-    #'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',
-)
-
-EXTRA_INSTALLED_APPS = tuple()
-
-# A sample logging configuration. The only tangible logging
-# performed by this configuration is to send an email to
-# the site admins on every HTTP 500 error when DEBUG=False.
-# 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,
-    'filters': {
-        'require_debug_false': {
-            '()': 'django.utils.log.RequireDebugFalse'
-        }
-    },
-    'handlers': {
-        'mail_admins': {
-            'level': 'ERROR',
-            'filters': ['require_debug_false'],
-            'class': 'django.utils.log.AdminEmailHandler'
-        }
-    },
-    'loggers': {
-        'django.request': {
-            'handlers': ['mail_admins'],
-            'level': 'ERROR',
-            'propagate': True,
-        },
-    }
-}
-
-TEMPLATE_CONTEXT_PROCESSORS = (
-    "django.contrib.auth.context_processors.auth",
-    "django.core.context_processors.debug",
-    "django.core.context_processors.i18n",
-    "django.core.context_processors.media",
-    "django.core.context_processors.static",
-    "django.core.context_processors.tz",
-    "django.core.context_processors.request",
-    "coin.isp_database.context_processors.branding",
-    "coin.context_processors.installed_apps",
-    "django.contrib.messages.context_processors.messages")
-
-AUTH_USER_MODEL = 'members.Member'
-
-AUTHENTICATION_BACKENDS = (
-    'django.contrib.auth.backends.ModelBackend',
-)
-
-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"
-
-# Do we use LDAP or not
-LDAP_ACTIVATE = False
-
-# Not setting them results in NameError
-LDAP_USER_BASE_DN = None
-VPN_CONF_BASE_DN = None
-
-# Membership configuration
-# Default cotisation in €, per year
-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 = ''
-
-# Pattern used to display a unique reference for any subscription
-# Helpful for bank wire transfer identification
-SUBSCRIPTION_REFERENCE = 'REF-{subscription.offer.reference}-{subscription.pk}'
-
-# Reset session if cookie older than 2h.
-SESSION_COOKIE_AGE = 7200
-
-# RSS/Atom feeds to display on dashboard
-# 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),
-)
 # Surcharge les paramètres en utilisant le fichier settings_local.py
 try:
     from settings_local import *

+ 262 - 0
coin/settings_base.py

@@ -0,0 +1,262 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+import os
+import ldap
+
+# Django settings for coin project.
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(__file__))
+
+PROJECT_PATH = os.path.abspath(os.path.dirname(__file__))
+DEBUG = TEMPLATE_DEBUG = True
+
+ADMINS = (
+    # ('Your Name', 'your_email@example.com'),
+)
+
+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
+    },
+}
+
+# Hosts/domain names that are valid for this site; required if DEBUG is False
+# See https://docs.djangoproject.com/en/1.7/ref/settings/#allowed-hosts
+ALLOWED_HOSTS = []
+
+# Local time zone for this installation. Choices can be found here:
+# 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'
+
+# Language code for this installation. All choices can be found here:
+# http://www.i18nguy.com/unicode/language-identifiers.html
+LANGUAGE_CODE = 'fr-fr'
+
+SITE_ID = 1
+
+# If you set this to False, Django will make some optimizations so as not
+# to load the internationalization machinery.
+USE_I18N = True
+
+# If you set this to False, Django will not format dates, numbers and
+# calendars according to the current locale.
+USE_L10N = True
+
+# If you set this to False, Django will not use timezone-aware datetimes.
+USE_TZ = True
+
+# Default URL for login and 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/')
+
+# 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/'
+
+# 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/')
+
+# URL prefix for static files.
+# Example: "http://example.com/static/", "http://static.example.com/"
+STATIC_URL = '/static/'
+
+# Additional locations of static files
+STATICFILES_DIRS = (
+    # Put strings here, like "/home/html/static" or "C:/www/django/static".
+    # Always use forward slashes, even on Windows.
+    # Don't forget to use absolute paths, not relative paths.
+)
+
+# 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',
+)
+
+# 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/')
+
+# 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'
+
+# Make this unique, and don't share it with anybody.
+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',
+)
+
+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',
+    # Uncomment the next line for simple clickjacking protection:
+    # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+)
+
+ROOT_URLCONF = 'coin.urls'
+
+# Python dotted path to the WSGI application used by Django's runserver.
+WSGI_APPLICATION = 'coin.wsgi.application'
+
+TEMPLATE_DIRS = (
+    # Only absolute paths, always forward slashes
+    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',
+    # Uncomment the next line to enable the admin:
+    'django.contrib.admin',
+    'netfields',
+    # Uncomment the next line to enable admin documentation:
+    #'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',
+)
+
+EXTRA_INSTALLED_APPS = tuple()
+
+# A sample logging configuration. The only tangible logging
+# performed by this configuration is to send an email to
+# the site admins on every HTTP 500 error when DEBUG=False.
+# 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,
+    '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,
+        },
+        'django': {
+            'handlers': ['console'],
+            'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
+        },
+    }
+}
+
+TEMPLATE_CONTEXT_PROCESSORS = (
+    "django.contrib.auth.context_processors.auth",
+    "django.core.context_processors.debug",
+    "django.core.context_processors.i18n",
+    "django.core.context_processors.media",
+    "django.core.context_processors.static",
+    "django.core.context_processors.tz",
+    "django.core.context_processors.request",
+    "coin.isp_database.context_processors.branding",
+    "coin.context_processors.installed_apps",
+    "django.contrib.messages.context_processors.messages")
+
+AUTH_USER_MODEL = 'members.Member'
+
+AUTHENTICATION_BACKENDS = (
+    'django.contrib.auth.backends.ModelBackend',
+)
+
+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"
+
+# Do we use LDAP or not
+LDAP_ACTIVATE = False
+
+# Not setting them results in NameError
+LDAP_USER_BASE_DN = None
+VPN_CONF_BASE_DN = None
+
+# Membership configuration
+# Default cotisation in €, per year
+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 = ''
+
+# Pattern used to display a unique reference for any subscription
+# Helpful for bank wire transfer identification
+SUBSCRIPTION_REFERENCE = 'REF-{subscription.offer.reference}-{subscription.pk}'
+
+# Reset session if cookie older than 2h.
+SESSION_COOKIE_AGE = 7200
+
+# RSS/Atom feeds to display on dashboard
+# 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),
+)

+ 11 - 0
coin/settings_test.py

@@ -0,0 +1,11 @@
+from settings_base import *
+
+# settings for unit tests
+
+EXTRA_INSTALLED_APPS = (
+    'hardware_provisioning',
+    'vpn',
+)
+
+TEMPLATE_DIRS = EXTRA_TEMPLATE_DIRS + TEMPLATE_DIRS
+INSTALLED_APPS = INSTALLED_APPS + EXTRA_INSTALLED_APPS

+ 16 - 0
coin/utils.py

@@ -171,6 +171,22 @@ def disable_for_loaddata(signal_handler):
     return wrapper
 
 
+def postgresql_regexp(regexp):
+    """ Make a PCRE regexp PostgreSQL compatible (kinda)
+
+    PostgreSQL forbids using capture-group names, this function removes them.
+    :param regexp: a PCRE regexp or pattern
+    :return a PostgreSQL regexp
+    """
+    try:
+        original_pattern = regexp.pattern
+    except AttributeError:
+        original_pattern = regexp
+
+    return re.sub(
+        r'\?P<.*?>', '', original_pattern)
+
+
 if __name__ == '__main__':
     # ldap_hash expects an unicode string
     print(ldap_hash(sys.argv[1].decode("utf-8")))

+ 8 - 0
coin/validation.py

@@ -2,6 +2,9 @@
 from __future__ import unicode_literals
 
 from django.core.exceptions import ValidationError
+from django.core.validators import RegexValidator
+
+from .utils import re_chat_url
 
 
 def validate_v4(address):
@@ -12,3 +15,8 @@ def validate_v4(address):
 def validate_v6(address):
     if address.version != 6:
         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>")

+ 20 - 0
hardware_provisioning/migrations/0013_auto_20161110_2246.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0012_auto_20161110_2225'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='item',
+            name='serial',
+            field=models.CharField(help_text='ou toute autre r\xe9f\xe9rence unique)', unique=True, max_length=250, verbose_name='N\xb0 de s\xe9rie', blank=True, null=True),
+            preserve_default=True,
+        ),
+    ]

+ 19 - 0
hardware_provisioning/migrations/0014_auto_20170422_1847.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('hardware_provisioning', '0013_auto_20161110_2246'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='item',
+            name='buy_date',
+            field=models.DateField(null=True, verbose_name='date d\u2019achat', blank=True),
+        ),
+    ]

+ 11 - 3
hardware_provisioning/models.py

@@ -45,9 +45,9 @@ class Item(models.Model):
         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,
+        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')
+    buy_date = models.DateField(verbose_name='date d’achat' , blank=True , null=True)
     owner = models.ForeignKey(
         settings.AUTH_USER_MODEL,
         verbose_name='Propriétaire',
@@ -56,12 +56,20 @@ class Item(models.Model):
         help_text="dans le cas de matériel n'appartenant pas à l'association")
     comment = models.TextField(verbose_name='commentaire', blank=True,
                                null=True)
-                               
+
     objects = ItemQuerySet().as_manager()
 
     def __unicode__(self):
         return self.designation
 
+    def save(self, *args, **kwargs):
+        # workaround for unique=True, null=True
+        # see https://code.djangoproject.com/ticket/4136#comment:33
+        self.mac_address = self.mac_address or None
+        self.serial = self.serial or None
+
+        return super(Item, self).save(*args, **kwargs)
+
     def get_current_loan(self):
         """
         Returns the current Loan for this Item, if exists, or None.

+ 8 - 6
requirements.txt

@@ -1,16 +1,18 @@
-Django==1.7.11
+Django>=1.8.17,<1.9
 psycopg2==2.5.2
 python-ldap==2.4.15
 wsgiref==0.1.2
 python-dateutil==2.2
-django-autocomplete-light==2.0.7
+django-autocomplete-light==2.1.1
 django-activelink==0.4
 html2text
-django-polymorphic==0.6
-django-sendfile==0.3.6
+django-polymorphic==0.7.2
+django-sendfile==0.3.10
 django-localflavor==1.1
--e git+https://code.ffdn.org/zorun/django-postgresql-netfields.git#egg=django-netfields
--e git+https://github.com/jlaine/django-ldapdb@1c4f9f29e52176f4367a1dffec2ecd2e123e2e7a#egg=django-ldapdb
+django-netfields>=0.4,<0.5
+django-ldapdb>=0.4.0,<5.0
+django-multiselectfield>=0.1.5
 feedparser
 six==1.10.0
 WeasyPrint==0.31
+freezegun==0.3.8

+ 4 - 0
setup.cfg

@@ -0,0 +1,4 @@
+[tool:pytest]
+DJANGO_SETTINGS_MODULE=coin.settings_test
+norecursedirs = .* _build tmp*  build dist *.egg *venv*
+python_files = tests* test*

+ 42 - 0
vpn/migrations/0001_squashed_0002_remove_vpnconfiguration_comment.py

@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import netfields.fields
+import coin.validation
+import coin.mixins
+
+
+class Migration(migrations.Migration):
+
+    replaces = [('vpn', '0001_initial'), ('vpn', '0002_remove_vpnconfiguration_comment')]
+
+    dependencies = [
+        ('configuration', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='VPNConfiguration',
+            fields=[
+                ('configuration_ptr', models.OneToOneField(parent_link=True, auto_created=True, primary_key=True, serialize=False, to='configuration.Configuration')),
+                ('activated', models.BooleanField(default=False, verbose_name='activ\xe9')),
+                ('login', models.CharField(help_text='leave empty for automatic generation', unique=True, max_length=50, verbose_name='identifiant', blank=True)),
+                ('password', models.CharField(max_length=256, null=True, verbose_name='mot de passe', blank=True)),
+                ('ipv4_endpoint', netfields.fields.InetAddressField(validators=[coin.validation.validate_v4], max_length=39, blank=True, help_text='Adresse IPv4 utilis\xe9e par d\xe9faut sur le VPN', null=True, verbose_name='IPv4')),
+                ('ipv6_endpoint', netfields.fields.InetAddressField(validators=[coin.validation.validate_v6], max_length=39, blank=True, help_text='Adresse IPv6 utilis\xe9e par d\xe9faut sur le VPN', null=True, verbose_name='IPv6')),
+            ],
+            options={
+                'verbose_name': 'VPN',
+            },
+            bases=(coin.mixins.CoinLdapSyncMixin, 'configuration.configuration'),
+        ),
+        migrations.CreateModel(
+            name='LdapVPNConfig',
+            fields=[
+            ],
+            options={
+                'managed': False,
+            },
+        ),
+    ]

+ 12 - 0
vpn/tests.py

@@ -1,7 +1,10 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+from unittest import skipUnless
+
 from django.test import TestCase
+from django.conf import settings
 
 from coin.offers.models import Offer, OfferSubscription
 from coin.resources.models import IPPool, IPSubnet
@@ -10,6 +13,9 @@ from coin.members.tests import MemberTestsUtils
 
 from .models import VPNConfiguration
 
+USING_POSTGRES = (settings.DATABASES['default']['ENGINE']
+                  ==
+                  'django.db.backends.postgresql_psycopg2')
 
 class VPNTestCase(TestCase):
     fixtures = ['example_pools.json', 'offers.json']
@@ -56,10 +62,12 @@ class VPNTestCase(TestCase):
             vpn.delete()
         Member.objects.get().delete()
 
+    @skipUnless(USING_POSTGRES, "Using a postgresql-only field")
     def test_has_ipv4_endpoint(self):
         vpn = VPNConfiguration.objects.all()[0]
         self.assertIsNotNone(vpn.ipv4_endpoint)
 
+    @skipUnless(USING_POSTGRES, "Using a postgresql-only field")
     def test_has_correct_ipv4_endpoint(self):
         """If there is not endpoint, we consider it to be correct."""
         vpn = VPNConfiguration.objects.all()[0]
@@ -67,10 +75,12 @@ class VPNTestCase(TestCase):
             subnet = vpn.ip_subnet.get(ip_pool=self.v4_pool)
             self.assertIn(vpn.ipv4_endpoint, subnet.inet)
 
+    @skipUnless(USING_POSTGRES, "Using a postgresql-only field")
     def test_has_ipv6_endpoint(self):
         vpn = VPNConfiguration.objects.all()[0]
         self.assertIsNotNone(vpn.ipv6_endpoint)
 
+    @skipUnless(USING_POSTGRES, "Using a postgresql-only field")
     def test_has_correct_ipv6_endpoint(self):
         """If there is not endpoint, we consider it to be correct."""
         vpn = VPNConfiguration.objects.all()[0]
@@ -78,6 +88,7 @@ class VPNTestCase(TestCase):
             subnet = vpn.ip_subnet.get(ip_pool=self.v6_pool)
             self.assertIn(vpn.ipv6_endpoint, subnet.inet)
 
+    @skipUnless(USING_POSTGRES, "Using a postgresql-only field")
     def test_change_v4subnet_is_vpn_endpoint_correct(self):
         vpn = VPNConfiguration.objects.all()[0]
         subnet = vpn.ip_subnet.get(ip_pool=self.v4_pool)
@@ -86,6 +97,7 @@ class VPNTestCase(TestCase):
         subnet.save()
         self.test_has_correct_ipv4_endpoint()
 
+    @skipUnless(USING_POSTGRES, "Using a postgresql-only field")
     def test_change_v6subnet_is_vpn_endpoint_correct(self):
         vpn = VPNConfiguration.objects.all()[0]
         subnet = vpn.ip_subnet.get(ip_pool=self.v6_pool)