Browse Source

Merge branch 'master' of git.illyse.org:coin

Oriane 11 years ago
parent
commit
efa294d503

+ 1 - 2
README.md

@@ -51,8 +51,7 @@ settings:
 
 To sync database, the first time run :
 
-python manage.py syncdb
-python manage.py migrate
+python manage.py syncdb --migrate
 
 Then at each code update :
 

+ 2 - 2
coin/billing/admin.py

@@ -43,8 +43,8 @@ class InvoiceAdmin(admin.ModelAdmin):
     fields = (('number', 'date', 'status'),
        ('date_due'),
        ('member'),
-       'amount')
-    readonly_fields = ('amount',)
+       ('amount','amount_paid'))
+    readonly_fields = ('amount','amount_paid')
     form = autocomplete_light.modelform_factory(Invoice)
 
     def get_formsets(self, request, obj=None):

+ 2 - 2
coin/billing/create_subscriptions_invoices.py

@@ -10,7 +10,6 @@ from coin.members.models import Member
 from coin.billing.models import Invoice, InvoiceDetail
 
 
-
 def create_all_members_invoices_for_a_period(date=datetime.date.today()):
     """
     Pour chaque membre ayant au moins un abonnement actif, génère les factures
@@ -122,7 +121,8 @@ def create_member_invoice_for_a_period(member, date):
             # 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à.)
             if (period_from<period_to):
-                # Ajout l'item de l'offre correspondant à l'abonnement à la facture
+                # Ajout l'item de l'offre correspondant à l'abonnement
+                # à la facture
                 invoice.details.create(label=offer.name,
                                        amount=offer.period_fees,
                                        quantity=quantity,

+ 94 - 0
coin/billing/migrations/0015_auto__chg_field_payment_amount.py

@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+
+        # Changing field 'Payment.amount'
+        db.alter_column(u'billing_payment', 'amount', self.gf('django.db.models.fields.DecimalField')(null=True, max_digits=5, decimal_places=2))
+
+    def backwards(self, orm):
+
+        # Changing field 'Payment.amount'
+        db.alter_column(u'billing_payment', 'amount', self.gf('django.db.models.fields.DecimalField')(null=True, max_digits=7, decimal_places=2))
+
+    models = {
+        u'billing.invoice': {
+            'Meta': {'object_name': 'Invoice'},
+            'date': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today', 'null': 'True'}),
+            'date_due': ('django.db.models.fields.DateField', [], {'default': 'datetime.datetime(2014, 3, 31, 0, 0)', 'null': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'member': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['members.Member']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}),
+            'number': ('django.db.models.fields.CharField', [], {'default': "u'201403-241-114'", 'unique': 'True', 'max_length': '25'}),
+            'status': ('django.db.models.fields.CharField', [], {'default': "'open'", 'max_length': '50'})
+        },
+        u'billing.invoicedetail': {
+            'Meta': {'object_name': 'InvoiceDetail'},
+            'amount': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'invoice': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'details'", 'to': u"orm['billing.Invoice']"}),
+            'label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'offersubscription': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['offers.OfferSubscription']", 'null': 'True', 'blank': 'True'}),
+            'period_from': ('django.db.models.fields.DateField', [], {'default': 'datetime.datetime(2014, 3, 1, 0, 0)', 'null': 'True', 'blank': 'True'}),
+            'period_to': ('django.db.models.fields.DateField', [], {'default': 'datetime.datetime(2014, 3, 31, 0, 0)', 'null': 'True', 'blank': 'True'}),
+            'quantity': ('django.db.models.fields.DecimalField', [], {'default': '1.0', 'null': 'True', 'max_digits': '4', 'decimal_places': '2'}),
+            'tax': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'null': 'True', 'max_digits': '4', 'decimal_places': '2'})
+        },
+        u'billing.payment': {
+            'Meta': {'object_name': 'Payment'},
+            'amount': ('django.db.models.fields.DecimalField', [], {'null': 'True', 'max_digits': '5', 'decimal_places': '2'}),
+            'date': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'invoice': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'payments'", 'to': u"orm['billing.Invoice']"}),
+            'payment_mean': ('django.db.models.fields.CharField', [], {'default': "'transfer'", 'max_length': '100', 'null': 'True'})
+        },
+        u'members.member': {
+            'Meta': {'object_name': 'Member'},
+            'address': ('django.db.models.fields.TextField', [], {}),
+            'city': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'country': ('django.db.models.fields.CharField', [], {'default': "'France'", 'max_length': '200'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '254'}),
+            'entry_date': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'home_phone_number': ('django.db.models.fields.CharField', [], {'max_length': '25', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'ldap_cn': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}),
+            'mobile_phone_number': ('django.db.models.fields.CharField', [], {'max_length': '25', 'blank': 'True'}),
+            'organization_name': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}),
+            'postal_code': ('django.db.models.fields.CharField', [], {'max_length': '15'}),
+            'resign_date': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
+            'status': ('django.db.models.fields.CharField', [], {'default': "'non_adherent'", 'max_length': '50'}),
+            'type': ('django.db.models.fields.CharField', [], {'default': "'personne_physique'", 'max_length': '20'})
+        },
+        u'offers.offer': {
+            'Meta': {'object_name': 'Offer'},
+            'billing_period': ('django.db.models.fields.IntegerField', [], {'default': '1'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'initial_fees': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'period_fees': ('django.db.models.fields.DecimalField', [], {'max_digits': '5', 'decimal_places': '2'}),
+            'service': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['offers.Service']"})
+        },
+        u'offers.offersubscription': {
+            'Meta': {'object_name': 'OfferSubscription'},
+            'commitment': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'member': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['members.Member']"}),
+            'offer': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['offers.Offer']"}),
+            'resign_date': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
+            'subscription_date': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today'})
+        },
+        u'offers.service': {
+            'Meta': {'object_name': 'Service'},
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+        }
+    }
+
+    complete_apps = ['billing']

+ 32 - 2
coin/billing/models.py

@@ -4,6 +4,8 @@ import calendar
 import random
 from decimal import Decimal
 from django.db import models
+from django.db.models.signals import post_save
+from django.dispatch import receiver
 from coin.offers.models import Offer, OfferSubscription
 from coin.members.models import Member
 
@@ -40,6 +42,18 @@ class Invoice(models.Model):
         for detail in self.details.all():
             total += detail.total()
         return total.quantize(Decimal('0.01'))
+    amount.short_description = 'Montant'
+
+    def amount_paid(self):
+        """
+        Calcul le montant payé de la facture en fonction des éléments
+        de paiements
+        """
+        total = Decimal('0.0')
+        for payment in self.payments.all():
+            total += payment.amount
+        return total.quantize(Decimal('0.01'))
+    amount_paid.short_description = 'Montant payé'
 
     def has_owner(self, uid):
         "Check if passed uid (ex gmajax) is owner of the invoice"
@@ -117,10 +131,26 @@ class Payment(models.Model):
                                     default='transfer',
                                     choices=PAYMENT_MEAN_CHOICES,
                                     verbose_name='Moyen de paiement')
-    amount = models.DecimalField(max_digits=7, decimal_places=2, null=True,
+    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')
+    invoice = models.ForeignKey(Invoice, verbose_name='Facture',
+                                related_name='payments')
+
+    def __unicode__(self):
+        return u'Paiment de %0.2f€' % self.amount
 
     class Meta:
         verbose_name = 'paiement'
+
+@receiver(post_save, sender=Payment)
+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
+       instance.invoice.status == 'open'):
+       instance.invoice.status = 'closed'
+       instance.invoice.save()
+

+ 2 - 1
coin/billing/urls.py

@@ -4,5 +4,6 @@ from coin.billing import views
 
 urlpatterns = patterns(
     '',
-    url(r'^invoice/(?P<id>.+).pdf$', views.invoice_pdf, name="invoice_pdf")
+    url(r'^invoice/(?P<id>.+).pdf$', views.invoice_pdf, name="invoice_pdf"),
+    url('invoice/create_all_members_invoices_for_a_period', views.gen_invoices)
 )

+ 4 - 0
coin/billing/views.py

@@ -5,7 +5,11 @@ from django.core.exceptions import PermissionDenied
 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
 
+def gen_invoices(request):
+    create_all_members_invoices_for_a_period()
+    return HttpResponse('blop')
 
 def invoice_pdf(request, id):
     """

+ 104 - 0
coin/members/migrations/0008_auto__add_field_member_user.py

@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        # Adding field 'Member.user'
+        db.add_column(u'members_member', 'user',
+                      self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['auth.User'], null=True, on_delete=models.SET_NULL),
+                      keep_default=False)
+
+
+    def backwards(self, orm):
+        # Deleting field 'Member.user'
+        db.delete_column(u'members_member', 'user_id')
+
+
+    models = {
+        u'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        u'auth.permission': {
+            'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        u'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        u'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        u'members.cryptokey': {
+            'Meta': {'object_name': 'CryptoKey'},
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'key': ('django.db.models.fields.TextField', [], {}),
+            'member': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['members.Member']"}),
+            'type': ('django.db.models.fields.CharField', [], {'max_length': '3'})
+        },
+        u'members.ldapgroup': {
+            'Meta': {'object_name': 'LdapGroup', 'managed': 'False'},
+            'dn': ('django.db.models.fields.CharField', [], {'max_length': '200'})
+        },
+        u'members.ldapuser': {
+            'Meta': {'object_name': 'LdapUser', 'managed': 'False'},
+            'dn': ('django.db.models.fields.CharField', [], {'max_length': '200'})
+        },
+        u'members.member': {
+            'Meta': {'object_name': 'Member'},
+            'address': ('django.db.models.fields.TextField', [], {}),
+            'city': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'country': ('django.db.models.fields.CharField', [], {'default': "'France'", 'max_length': '200'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '254'}),
+            'entry_date': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'home_phone_number': ('django.db.models.fields.CharField', [], {'max_length': '25', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+            'ldap_cn': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}),
+            'mobile_phone_number': ('django.db.models.fields.CharField', [], {'max_length': '25', 'blank': 'True'}),
+            'organization_name': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'}),
+            'postal_code': ('django.db.models.fields.CharField', [], {'max_length': '15'}),
+            'resign_date': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
+            'status': ('django.db.models.fields.CharField', [], {'default': "'non_adherent'", 'max_length': '50'}),
+            'type': ('django.db.models.fields.CharField', [], {'default': "'personne_physique'", 'max_length': '20'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['auth.User']", 'null': 'True', 'on_delete': 'models.SET_NULL'})
+        },
+        u'members.membershipfee': {
+            'Meta': {'object_name': 'MembershipFee'},
+            'amount': ('django.db.models.fields.IntegerField', [], {'default': "'20'"}),
+            'end_date': ('django.db.models.fields.DateField', [], {'default': 'datetime.datetime(2015, 4, 20, 0, 0)'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'member': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'membership_fees'", 'to': u"orm['members.Member']"}),
+            'start_date': ('django.db.models.fields.DateField', [], {'default': 'datetime.date.today'})
+        }
+    }
+
+    complete_apps = ['members']

+ 95 - 61
coin/members/models.py

@@ -15,8 +15,11 @@ from django.core import exceptions
 from ldapdb.models.fields import CharField, IntegerField, ListField
 from south.modelsinspector import add_ignored_fields
 from coin.offers.models import OfferSubscription
+from coin.models import CoinLdapSyncModel
+from django.contrib.auth.signals import user_logged_in
+from django.conf import settings
 
-class Member(models.Model):
+class Member(CoinLdapSyncModel):
 
     MEMBER_TYPE_CHOICES = (
         ('personne_physique', 'Personne physique'),
@@ -28,6 +31,9 @@ class Member(models.Model):
         ('demande_adhesion', "Demande d'adhésion"),
     )
 
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, default=None,
+                             verbose_name='Utilisateur Django',
+                             on_delete=models.SET_NULL)
     status = models.CharField(max_length=50, choices=MEMBER_STATUS_CHOICES,
                               default='non_adherent')
     type = models.CharField(max_length=20, choices=MEMBER_TYPE_CHOICES,
@@ -35,11 +41,11 @@ class Member(models.Model):
     first_name = models.CharField(max_length=200, verbose_name=u'Prénom')
     last_name = models.CharField(max_length=200, verbose_name=u'Nom')
     ldap_cn = models.CharField(max_length=200, blank=True,
-        help_text='Clé avec le LDAP. Laisser vide pour la générer'
-                  'automatiquement')
+                               help_text='Clé avec le LDAP. Laisser vide pour '
+                               'la générer automatiquement')
     organization_name = models.CharField(max_length=200, blank=True,
-        verbose_name='Nom de l\'organisme',
-        help_text='Pour une personne morale')
+                                         verbose_name='Nom de l\'organisme',
+                                         help_text='Pour une personne morale')
     email = models.EmailField(max_length=254, verbose_name=u'Courriel')
     home_phone_number = models.CharField(max_length=25, blank=True,
                                          verbose_name=u'Téléphone fixe')
@@ -58,7 +64,8 @@ class Member(models.Model):
                                   default=datetime.date.today,
                                   verbose_name='Date de première adhésion')
     resign_date = models.DateField(null=True, blank=True,
-        verbose_name='Date de départ de l\'association')
+                                   verbose_name='Date de départ de '
+                                   'l\'association')
 
     def __unicode__(self):
         name = self.first_name + ' ' + self.last_name
@@ -84,6 +91,55 @@ class Member(models.Model):
             Q(subscription_date__lte=date),
             Q(resign_date__isnull=True) | Q(resign_date__gte=date))
 
+    def get_automatic_ldap_cn(self):
+        """
+        Calcul le login / ldap_cn automatiquement en fonction du nom et du prénom
+        """
+        # Première lettre de chaque partie du prénom
+        first_name_letters = ''.join(
+            [c[0] for c in self.first_name.split('-')]
+        )
+        # Concaténer avec nom de famille
+        ldap_cn = ('%s%s' % (first_name_letters, self.last_name))
+        # Remplacer ou enlever les caractères non ascii
+        ldap_cn = unicodedata.normalize('NFD', ldap_cn)\
+            .encode('ascii', 'ignore')
+        # Enlever ponctuation et espace
+        ldap_cn = ldap_cn.translate(None, string.punctuation + ' ')
+        # En minuscule
+        ldap_cn = ldap_cn.lower()
+
+        return ldap_cn
+
+    def sync_to_ldap(self, creation):
+        """
+        Update LDAP data when a member is saved
+        """
+
+        assert self.ldap_cn, ('Can\'t sync with LDAP because missing ldap_cn '
+                              'value for the Member : %s' % self)
+
+        if not creation:
+                ldap_user = LdapUser.objects.get(pk=self.ldap_cn)
+
+        if creation:
+            max_uidNumber = LdapUser.objects.order_by('-uidNumber')[0].uidNumber
+
+            ldap_user = LdapUser()
+            ldap_user.pk = self.ldap_cn
+            ldap_user.uid = self.ldap_cn
+            ldap_user.nick_name = self.ldap_cn
+            ldap_user.uidNumber = max_uidNumber + 1
+
+        ldap_user.last_name = self.last_name
+        ldap_user.first_name = self.first_name
+        ldap_user.save()
+
+        if creation:
+            ldap_group = LdapGroup.objects.get(pk='coin')
+            ldap_group.members.append(ldap_user.pk)
+            ldap_group.save()
+
     class Meta:
         verbose_name = 'membre'
 
@@ -121,7 +177,7 @@ class MembershipFee(models.Model):
 
     def __unicode__(self):
         return (u'%s - %s - %i€' % (self.member, self.start_date,
-                                     self.amount))
+                                    self.amount))
 
     class Meta:
         verbose_name = 'cotisation'
@@ -154,6 +210,7 @@ class LdapUser(ldapdb.models.Model):
 
 
 class LdapGroup(ldapdb.models.Model):
+
     """
     Class for representing an LDAP group entry.
     """
@@ -172,10 +229,20 @@ class LdapGroup(ldapdb.models.Model):
     class Meta:
         managed = False  # Indique à South de ne pas gérer le model LdapGroup
 
-#Indique à South de ne pas gérer les models LdapUser et LdapGroup
+# Indique à South de ne pas gérer les models LdapUser et LdapGroup
 add_ignored_fields(["^ldapdb\.models\.fields"])
 
 
+@receiver(pre_save, sender=Member)
+def define_ldap_cn(sender, instance, **kwargs):
+    """
+    Lors de la sauvegarde d'un membre. Si le champ ldap_cn n'est pas définit,
+    le calcul automatiquement en fonction du nom et du prénom
+    """
+    if not instance.ldap_cn and self.pk:
+        instance.ldap_cn = instance.get_automatic_ldap_cn()
+
+
 @receiver(pre_save, sender=LdapUser)
 def change_password(sender, instance, **kwargs):
     """
@@ -202,61 +269,10 @@ def define_display_name(sender, instance, **kwargs):
                                            instance.last_name)
 
 
-@receiver(pre_save, sender=Member)
-def define_ldap_cn(sender, instance, **kwargs):
-    """
-    Lors de la sauvegarde d'un membre. Si le champ ldap_cn n'est pas définit,
-    le calcul automatiquement en fonction du nom et du prénom
-    """
-    if not instance.ldap_cn:
-        # Première lettre de chaque partie du prénom
-        first_name_letters = ''.join(
-            [c[0] for c in instance.first_name.split('-')]
-        )
-        # Concaténer avec nom de famille
-        ldap_cn = ('%s%s' % (first_name_letters, instance.last_name))
-        # Remplacer ou enlever les caractères non ascii
-        ldap_cn = unicodedata.normalize('NFD', ldap_cn)\
-            .encode('ascii', 'ignore')
-        # Enlever ponctuation et espace
-        ldap_cn = ldap_cn.translate(None, string.punctuation + ' ')
-        # En minuscule
-        ldap_cn = ldap_cn.lower()
-
-        instance.ldap_cn = ldap_cn
-
-
-@receiver(post_save, sender=Member)
-def sync_ldap(sender, instance, created, **kwargs):
-    """
-    Update LDAP data when a member is saved
-    """
-    if not created:
-        ldap_user = LdapUser.objects.get(pk=instance.ldap_cn)
-
-    if created:
-        max_uidNumber = LdapUser.objects.order_by('-uidNumber')[0].uidNumber
-
-        ldap_user = LdapUser()
-        ldap_user.pk = instance.ldap_cn
-        ldap_user.uid = instance.ldap_cn
-        ldap_user.nick_name = instance.ldap_cn
-        ldap_user.uidNumber = max_uidNumber + 1
-
-    ldap_user.last_name = instance.last_name
-    ldap_user.first_name = instance.first_name
-    ldap_user.save()
-
-    if created:
-        ldap_group = LdapGroup.objects.get(pk='coin')
-        ldap_group.members.append(ldap_user.pk)
-        ldap_group.save()
-
-
 @receiver(post_delete, sender=Member)
 def remove_ldap_user_from_coin_group_when_deleting_member(sender,
-                                                                 instance,
-                                                                 **kwargs):
+                                                          instance,
+                                                          **kwargs):
     """
     Lorsqu'un membre est supprimé du SI, son utilisateur LDAP correspondant est
     sorti du groupe "coin"
@@ -266,6 +282,24 @@ def remove_ldap_user_from_coin_group_when_deleting_member(sender,
         ldap_group.members.remove(instance.ldap_cn)
         ldap_group.save()
 
+
+@receiver(user_logged_in)
+def define_member_user(sender, request, user, **kwargs):
+    """
+    Lorsqu'un utilisateur se connect avec succes, fait le lien entre le membre
+    et l'utilisateur en définissant le champ user du model membre ayant le 
+    ldap_cn utilisé pour la connexion
+    """
+    member = Member.objects.get(ldap_cn=user.username)
+    if not member.user:
+        member.user = user
+        member.save()
+    elif member.user.username != user.username:
+        raise Exception('Un membre avec cet ldap_cn existe en base de donnée '
+                        'mais l\'utilisateur auquel il est rattaché ne '
+                        'correspond pas.')
+    
+
 #==============================================================================
 # @receiver(pre_save, sender = LdapUser)
 # def ssha_password(sender, **kwargs):

+ 126 - 63
coin/members/tests.py

@@ -1,13 +1,16 @@
 # -*- coding: utf-8 -*-
 import os
+from django import db
 from django.test import TestCase, Client
 from django.contrib.auth.models import User
 from coin.members.models import Member, LdapUser, LdapGroup
 import logging
+import ldapdb
+from pprint import pprint
 
 
 class MemberTests(TestCase):
-        
+
     def test_when_creating_member_a_ldapuser_is_also_created_with_same_data(self):
         """
         Test que lors de la création d'un nouveau membre, une entrée 
@@ -15,26 +18,26 @@ class MemberTests(TestCase):
         les mêmes données.
         Cela concerne le nom et le prénom
         """
+
         #~ Créé un membre
-        
-        first_name = u'Gérard';
-        last_name = u'Majax';
-        ldap_cn =  MemberTestsUtils.get_random_ldap_cn()
-        member = Member(first_name = first_name,
-                        last_name = last_name,
-                        ldap_cn = ldap_cn)
+        first_name = u'Gérard'
+        last_name = u'Majax'
+        ldap_cn = MemberTestsUtils.get_random_ldap_cn()
+        member = Member(first_name=first_name,
+                        last_name=last_name,
+                        ldap_cn=ldap_cn)
         member.save()
-        
+
         #~ Récupère l'utilisateur LDAP et fait les tests
         ldap_user = LdapUser.objects.get(pk=ldap_cn)
-        
+
         self.assertEqual(ldap_user.first_name, first_name)
         self.assertEqual(ldap_user.last_name, last_name)
         self.assertEqual(ldap_user.pk, ldap_cn)
-        
-        member.delete();
-        ldap_user.delete();
-    
+
+        member.delete()
+        ldap_user.delete()
+
     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 
@@ -42,28 +45,29 @@ class MemberTests(TestCase):
         Cela concerne le no met le prénom
         """
         #~ Créé un membre
-        first_name = u'Ronald';
-        last_name = u'Mac Donald';
-        ldap_cn =  MemberTestsUtils.get_random_ldap_cn()
-        member = Member(first_name = first_name, last_name = last_name, ldap_cn = ldap_cn)
+        first_name = u'Ronald'
+        last_name = u'Mac Donald'
+        ldap_cn = MemberTestsUtils.get_random_ldap_cn()
+        member = Member(first_name=first_name,
+                        last_name=last_name, ldap_cn=ldap_cn)
         member.save()
-        
+
         #~  Le modifie
-        new_first_name = u'José';
-        new_last_name = u'Bové';
+        new_first_name = u'José'
+        new_last_name = u'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
         ldap_user = LdapUser.objects.get(pk=ldap_cn)
-        
+
         self.assertEqual(ldap_user.first_name, new_first_name)
         self.assertEqual(ldap_user.last_name, new_last_name)
 
-        member.delete();
-        ldap_user.delete();
-    
+        member.delete()
+        ldap_user.delete()
+
     def test_when_creating_member_corresponding_ldap_user_is_in_coin_ldap_group(self):
         """
         Test que l'utilisateur Ldap fraichement créé est bien dans le group "coin"
@@ -71,23 +75,23 @@ class MemberTests(TestCase):
         est bien retiré du groupe.
         """
         #~ Créé un membre
-        ldap_cn =  MemberTestsUtils.get_random_ldap_cn()
-        member = Member(first_name = 'Canard', last_name = 'WC', ldap_cn = ldap_cn)
+        ldap_cn = MemberTestsUtils.get_random_ldap_cn()
+        member = Member(first_name='Canard',
+                        last_name='WC', ldap_cn=ldap_cn)
         member.save()
-        
+
         #~ Récupère le group "coin" et test que l'utilisateur y est présent
         ldap_group = LdapGroup.objects.get(pk="coin")
         self.assertEqual(ldap_cn in ldap_group.members, True)
-        
+
         #~ Supprime l'utilisateur
-        member.delete();
-        
+        member.delete()
+
         #~ Récupère le group "coin" et test que l'utilisateur n'y est plus
         ldap_group = LdapGroup.objects.get(pk="coin")
         self.assertEqual(ldap_cn in ldap_group.members, False)
-        
-        LdapUser.objects.get(pk=ldap_cn).delete();
 
+        LdapUser.objects.get(pk=ldap_cn).delete()
 
     def test_change_password_and_auth(self):
         """
@@ -96,24 +100,24 @@ class MemberTests(TestCase):
         """
         ldap_cn = MemberTestsUtils.get_random_ldap_cn()
         password = "1234"
-        
+
          #~ Créé un nouveau membre
-        member = Member(first_name = u'Passe-partout', last_name = u'Du fort Boyard', ldap_cn = ldap_cn)
+        member = Member(first_name=u'Passe-partout',
+                        last_name=u'Du fort Boyard', ldap_cn=ldap_cn)
         member.save()
-        
+
         #~ Récupère l'utilisateur LDAP
         ldap_user = LdapUser.objects.get(pk=ldap_cn)
-        
+
         #~ Change son mot de passe
         member.change_password(password)
 
         #~ Test l'authentification
         c = Client()
         self.assertEqual(c.login(username=ldap_cn, password=password), True)
-        
-        member.delete();
-        ldap_user.delete();
 
+        member.delete()
+        ldap_user.delete()
 
     def test_when_creating_member_ldap_display_name_is_well_defined(self):
         """
@@ -123,17 +127,18 @@ class MemberTests(TestCase):
         first_name = u'Gérard'
         last_name = u'Majax'
         ldap_cn = MemberTestsUtils.get_random_ldap_cn()
-        member = Member(first_name = first_name, last_name = last_name, ldap_cn = ldap_cn)
+        member = Member(first_name=first_name,
+                        last_name=last_name, ldap_cn=ldap_cn)
         member.save()
-        
+
         #~ Récupère l'utilisateur LDAP
         ldap_user = LdapUser.objects.get(pk=ldap_cn)
-        
-        self.assertEqual(ldap_user.display_name, '%s %s' % (first_name, last_name))
-        
-        member.delete();
-        ldap_user.delete();
 
+        self.assertEqual(ldap_user.display_name, '%s %s' %
+                         (first_name, last_name))
+
+        member.delete()
+        ldap_user.delete()
 
     def test_when_creating_member_ldap_cn_is_well_defined(self):
         """
@@ -145,28 +150,83 @@ class MemberTests(TestCase):
         random = os.urandom(4).encode('hex')
         first_name = u'Gérard-Étienne'
         last_name = u'Majax de la Boétie-Blop' + random
-               
+
         control = 'gemajaxdelaboetieblop' + random
-        
-        member = Member(first_name = first_name, last_name = last_name)
+
+        member = Member(first_name=first_name, last_name=last_name)
         member.save()
 
         self.assertEqual(member.ldap_cn, control)
-        
-        member.delete();
-        LdapUser.objects.get(pk=member.ldap_cn).delete();
+
+        member.delete()
+        LdapUser.objects.get(pk=member.ldap_cn).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
+        échoue (ici mauvais mot de passe), rien n'est sauvegardé en base
+        """
+        # 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):
+                dbconnection.settings_dict['PASSWORD'] = 'wrong password test'
+
+        # Créé un membre
+        first_name = u'Du'
+        last_name = u'Pont'
+        ldap_cn = MemberTestsUtils.get_random_ldap_cn()
+        member = Member(first_name=first_name,
+                        last_name=last_name, ldap_cn=ldap_cn)
+
+        # Le sauvegarde en base de donnée
+        # Le save devrait renvoyer une exception parceque le LDAP échoue
+        self.assertRaises(Exception, member.save)
+
+        # On s'assure, malgré l'exception, que le membre n'est pas en base
+        with self.assertRaises(Member.DoesNotExist):
+            Member.objects.get(ldap_cn=ldap_cn)
+
+    def test_when_user_login_member_user_field_is_updated(self):
+        """
+        Test que lorqu'un utilisateur se connect, le champ user du membre
+        correspondant est mis à jour convenablement
+        """
+        # Créé un membre
+        first_name = u'Du'
+        last_name = u'Pond'
+        password = '1234'
+        ldap_cn = MemberTestsUtils.get_random_ldap_cn()
+        member = Member(first_name=first_name,
+                        last_name=last_name, ldap_cn=ldap_cn)
+        member.save()
+        member.change_password(password)
+
+        # Vérifie que user non définit
+        self.assertIsNone(member.user)
+
+        # Connection
+        c = Client()
+        self.assertEqual(c.login(username=ldap_cn, password=password), True)
+
+        # Vérifie que user définit
+        member = Member.objects.get(ldap_cn=ldap_cn)
+        self.assertIsNotNone(member.user)
 
 
 class MemberAdminTests(TestCase):
+
     def setUp(self):
         #~ Client web
         self.client = Client()
         #~ Créé un superuser
         self.admin_user_password = '1234'
-        self.admin_user = User.objects.create_superuser('test_admin_user', 'i@mail.com', self.admin_user_password)
+        self.admin_user = User.objects.create_superuser(
+            'test_admin_user', 'i@mail.com', self.admin_user_password)
         #~ Connection
-        self.assertEqual(self.client.login(username = self.admin_user.username, password = self.admin_user_password),True)
-        
+        self.assertEqual(self.client.login(
+            username=self.admin_user.username, password=self.admin_user_password), True)
+
     def test_cant_change_ldap_cn_when_editing(self):
         """
         Vérifie que dans l'admin Django, le champ ldap_cn n'est pad modifiable
@@ -176,20 +236,23 @@ class MemberAdminTests(TestCase):
         first_name = u'Gérard'
         last_name = u'Majax'
         ldap_cn = MemberTestsUtils.get_random_ldap_cn()
-        member = Member(first_name = first_name, last_name = last_name, ldap_cn = ldap_cn)
+        member = Member(first_name=first_name,
+                        last_name=last_name, ldap_cn=ldap_cn)
         member.save()
-        
+
         edit_page = self.client.get('/admin/members/member/%i/' % member.id)
         self.assertNotContains(edit_page,
             '''<input id="id_ldap_cn" />''',
-            html=True)
-        
-        LdapUser.objects.get(pk=ldap_cn).delete();
+                               html=True)
+
+        LdapUser.objects.get(pk=ldap_cn).delete()
+
 
 class MemberTestsUtils(object):
+
     @staticmethod
     def get_random_ldap_cn():
         """
         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')

+ 38 - 0
coin/models.py

@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+from django.db import models
+from django.db import transaction
+
+
+class CoinLdapSyncModel(models.Model):
+
+    """
+    Ce modèle abstrait est à utiliser lorsqu'il s'agit de définir un modèle
+    à synchroniser avec le LDAP. Le modèle doit définir la methode sync_to_ldap
+    qui s'occupe du transfert vers le LDAP.
+    L'avantage de ce modèle est que si cette méthode échoue, la sauvegarde en
+    base de données échoue a son tour et rien n'est sauvegardé afin de conservé
+    l'intégrité.
+    """
+
+    def sync_to_ldap(self, creation):
+        raise NotImplementedError('Using CoinLdapSyncModel require '
+                                  'sync_to_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)
+
+        # Sauvegarde en base de donnée (mais sans commit, cf decorator)
+        super(CoinLdapSyncModel, self).save(*args, **kwargs)
+
+        # Sauvegarde dans le LDAP
+        # Si la sauvegarde LDAP échoue, Rollback la sauvegarde en base, sinon
+        # commit
+        try:
+            self.sync_to_ldap(creation)
+        except:
+            raise
+
+    class Meta:
+        abstract = True

+ 6 - 1
coin/reverse_dns/models.py

@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
 from django.db import models
 from django.core.exceptions import ValidationError
 from netfields import InetAddressField, NetManager
@@ -26,9 +27,13 @@ class ReverseDNSEntry(models.Model):
     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 self.ip:
             if not self.ip in self.ip_subnet.inet:
                 raise ValidationError('IP address must be included in the IP subnet.')
 
     def __unicode__(self):
-        return str(self.ip)
+        return u"{} → {}".format(self.ip, self.reverse)

+ 5 - 5
requirements.txt

@@ -1,8 +1,8 @@
-Django==1.6.1
-South==0.8.2
-django-auth-ldap==1.1.4
-psycopg2==2.5.1
-python-ldap==2.4.13
+Django==1.6.2
+South==0.8.4
+django-auth-ldap==1.2.0
+psycopg2==2.5.2
+python-ldap==2.4.15
 wsgiref==0.1.2
 python-dateutil==2.2
 django-autocomplete-light==2.0.0a8