models.py 17.6 KB
Newer Older
Floriane's avatar
Floriane committed
1
# -*- coding: utf-8 -*-
Baptiste Jonglez's avatar
Baptiste Jonglez committed
2
3
from __future__ import unicode_literals

Floriane's avatar
Floriane committed
4
import ldapdb.models
5
import unicodedata
Fabs's avatar
Fabs committed
6
import datetime
Floriane's avatar
Floriane committed
7
from django.db import models
8
from django.db.models import Q
CapsLock's avatar
PEP    
CapsLock committed
9
from django.db.models.signals import pre_save
Floriane's avatar
Floriane committed
10
from django.dispatch import receiver
11
from django.contrib.auth.models import AbstractUser
12
from django.conf import settings
13
from django.core.validators import RegexValidator
14
from django.core.exceptions import ValidationError
15
from ldapdb.models.fields import CharField, IntegerField, ListField
16

17
from coin.offers.models import OfferSubscription
18
from coin.mixins import CoinLdapSyncMixin
Baptiste Jonglez's avatar
Baptiste Jonglez committed
19
from coin import utils
20

Fabs's avatar
Fabs committed
21

22
class Member(CoinLdapSyncMixin, AbstractUser):
Fabs's avatar
Fabs committed
23

24
    # USERNAME_FIELD = 'login'
Fabs's avatar
Fabs committed
25
    REQUIRED_FIELDS = ['first_name', 'last_name', 'email', ]
Floriane's avatar
Floriane committed
26

Rogdham's avatar
Rogdham committed
27
    MEMBER_TYPE_CHOICES = (
Fabs's avatar
Fabs committed
28
29
        ('natural_person', 'Personne physique'),
        ('legal_entity', 'Personne morale'),
Rogdham's avatar
Rogdham committed
30
31
    )
    MEMBER_STATUS_CHOICES = (
Fabs's avatar
Fabs committed
32
33
34
        ('member', 'Adhérent'),
        ('not_member', 'Non adhérent'),
        ('pending', "Demande d'adhésion"),
Rogdham's avatar
Rogdham committed
35
36
37
    )

    status = models.CharField(max_length=50, choices=MEMBER_STATUS_CHOICES,
38
                              default='member', verbose_name='statut')
Rogdham's avatar
Rogdham committed
39
    type = models.CharField(max_length=20, choices=MEMBER_TYPE_CHOICES,
40
                            default='natural_person', verbose_name='type')
41

42
43
44
    nickname = models.CharField(max_length=64, blank=True,
                                verbose_name="nom d'usage",
                                help_text='Pseudonyme, …')
Fabs's avatar
PEP8    
Fabs committed
45
    organization_name = models.CharField(max_length=200, blank=True,
46
                                         verbose_name="nom de l'organisme",
47
                                         help_text='Pour une personne morale')
Fabs's avatar
Fabs committed
48
    home_phone_number = models.CharField(max_length=25, blank=True,
49
                                         verbose_name='téléphone fixe')
Fabs's avatar
Fabs committed
50
    mobile_phone_number = models.CharField(max_length=25, blank=True,
51
                                           verbose_name='téléphone mobile')
Baptiste Jonglez's avatar
Baptiste Jonglez committed
52
53
    # TODO: use a django module that provides an address model? (would
    # support more countries and address types)
Fabs's avatar
PEP8    
Fabs committed
54
55
    address = models.TextField(
        verbose_name='adresse postale', blank=True, null=True)
56
57
    postal_code = models.CharField(max_length=5, blank=True, null=True,
                                   validators=[RegexValidator(regex=r'^\d{5}$',
Fabs's avatar
PEP8    
Fabs committed
58
                                                              message='Code postal non valide.')],
Baptiste Jonglez's avatar
Baptiste Jonglez committed
59
                                   verbose_name='code postal')
Fabs's avatar
Fabs committed
60
    city = models.CharField(max_length=200, blank=True, null=True,
61
                            verbose_name='commune')
Fabs's avatar
Fabs committed
62
    country = models.CharField(max_length=200, blank=True, null=True,
Fabs's avatar
Fabs committed
63
                               default='France',
64
                               verbose_name='pays')
Fabs's avatar
PEP8    
Fabs committed
65
    resign_date = models.DateField(null=True, blank=True,
66
                                   verbose_name="date de départ de "
67
68
                                   "l'association",
                                   help_text="En cas de départ prématuré")
69
70
71
72
    comments = models.TextField(blank=True, verbose_name='commentaires',
                                help_text="Commentaires libres (informations"
                                " spécifiques concernant l'adhésion,"
                                " raison du départ, etc)")
Rogdham's avatar
Rogdham committed
73

74
75
    # Following fields are managed by the parent class AbstractUser :
    # username, first_name, last_name, email
Fabs's avatar
PEP8    
Fabs committed
76
77
78
    # However we hack the model to force theses fields to be required. (see
    # below)

79
80
81
82
83
    # This property is used to change password in LDAP. Used in sync_to_ldap.
    # Should not be defined manually. Prefer use set_password method that hash
    # passwords for both ldap and local db
    _password_ldap = None

84
85
86
87
88
89
90
91
92
93
    def clean(self):
        if self.type == 'legal_entity':
            if not self.organization_name:
                raise ValidationError("Le nom de l'organisme est obligatoire "
                                      "pour une personne morale")
        elif self.type == 'natural_person':
            if not (self.first_name and self.last_name):
                raise ValidationError("Le nom et prénom sont obligatoires "
                                      "pour une personne physique")

Floriane's avatar
Floriane committed
94
    def __unicode__(self):
95
96
97
98
99
100
        if self.type == 'legal_entity':
            return self.organization_name
        elif self.nickname:
            return self.nickname
        else:
            return self.first_name + ' ' + self.last_name
Floriane's avatar
Floriane committed
101

Fabs's avatar
Fabs committed
102
    def get_full_name(self):
103
        return str(self)
Fabs's avatar
Fabs committed
104
105

    def get_short_name(self):
106
        return self.username
Fabs's avatar
Fabs committed
107

Baptiste Jonglez's avatar
Baptiste Jonglez committed
108
    # Renvoie la date de fin de la dernière cotisation du membre
109
    def end_date_of_membership(self):
Baptiste Jonglez's avatar
Baptiste Jonglez committed
110
111
        x = self.membership_fees.order_by('-end_date')
        if x:
112
            return self.membership_fees.order_by('-end_date')[0].end_date
113
    end_date_of_membership.short_description = "Date de fin d'adhésion"
Fabs's avatar
PEP8    
Fabs committed
114

115
116
117
118
    def is_paid_up(self):
        """
        True si le membre est à jour de cotisation. False sinon
        """
CapsLock's avatar
PEP8    
CapsLock committed
119
120
        if self.end_date_of_membership() \
                and self.end_date_of_membership() >= datetime.date.today():
CapsLock's avatar
PEP    
CapsLock committed
121
            return True
122
123
124
        else:
            return False

125
    def set_password(self, new_password, *args, **kwargs):
Fabs's avatar
Fabs committed
126
        """
127
        Définit le mot de passe a sauvegarder en base et dans le LDAP
Fabs's avatar
Fabs committed
128
        """
129
130
        super(Member, self).set_password(new_password, *args, **kwargs)
        self._password_ldap = utils.ldap_hash(new_password)
Fabs's avatar
PEP8    
Fabs committed
131

132
    def get_active_subscriptions(self, date=None):
133
134
135
        """
        Return list of OfferSubscription which are active today
        """
136
137
        if date is None:
            date = datetime.date.today()
138
139
140
141
142
        return OfferSubscription.objects.filter(
            Q(member__exact=self.pk),
            Q(subscription_date__lte=date),
            Q(resign_date__isnull=True) | Q(resign_date__gte=date))

143
    def get_inactive_subscriptions(self, date=None):
144
145
146
        """
        Return list of OfferSubscription which are not active today
        """
147
148
        if date is None:
            date = datetime.date.today()
149
150
151
152
153
        return OfferSubscription.objects.filter(
            Q(member__exact=self.pk),
            Q(subscription_date__gt=date) |
            Q(resign_date__lt=date))

154
155
156
157
158
159
160
161
162
163
164
165
    def get_ssh_keys(self):
        # Quick & dirty, ensure that keys are unique (otherwise, LDAP complains)
        return list({k.key for k in self.cryptokey_set.filter(type='RSA')})

    def sync_ssh_keys(self):
        """
        Called whenever a SSH key is saved
        """
        ldap_user = LdapUser.objects.get(pk=self.username)
        ldap_user.sshPublicKey = self.get_ssh_keys()
        ldap_user.save()

166
    def sync_to_ldap(self, creation, update_fields, *args, **kwargs):
167
168
169
        """
        Update LDAP data when a member is saved
        """
Fabs's avatar
Fix    
Fabs committed
170

171
172
173
        # Do not perform LDAP query if no usefull fields to update are specified
        # in update_fields
        # Ex : at login, last_login field is updated by django auth module.
Fabs's avatar
Fabs committed
174
        if update_fields and set(['username', 'last_name', 'first_name']).isdisjoint(set(update_fields)):
175
176
177
            return

        # Fail if no username specified
178
        assert self.username, ('Can\'t sync with LDAP because missing username '
Fabs's avatar
PEP8    
Fabs committed
179
                               'value for the Member : %s' % self)
Fabs's avatar
Fix    
Fabs committed
180

Fabs's avatar
Fabs committed
181
182
183
184
185
186
187
188
189
190
191
        # If try to sync a superuser in creation mode
        # Try to retrieve the user in ldap. If exists, switch to update mode
        # This allow to create a superuser without having to delete corresponding
        # username in LDAP
        if self.is_superuser and creation:
            try:
                ldap_user = LdapUser.objects.get(pk=self.username)
                creation = False
            except LdapUser.DoesNotExist:
                pass

192
        if not creation:
193
            ldap_user = LdapUser.objects.get(pk=self.username)
194
195

        if creation:
196
197
198
199
200
            users = LdapUser.objects
            if users.exists():
                uid_number = users.order_by('-uidNumber')[0].uidNumber + 1
            else:
                uid_number = settings.LDAP_USER_FIRST_UID
201
            ldap_user = LdapUser()
202
203
204
            ldap_user.pk = self.username
            ldap_user.uid = self.username
            ldap_user.nick_name = self.username
205
            ldap_user.uidNumber = uid_number
206
            ldap_user.homeDirectory = '/home/' + self.username
207

208
209
210
211
212
213
        if self.type == 'natural_person':
            ldap_user.last_name = self.last_name
            ldap_user.first_name = self.first_name
        elif self.type == 'legal_entity':
            ldap_user.last_name = self.organization_name
            ldap_user.first_name = ""
Fabs's avatar
PEP8    
Fabs committed
214

215
        # If a password is definied in _password_ldap, change it in LDAP
216
        if self._password_ldap:
217
            # Make sure password is hashed
218
219
            ldap_user.password = utils.ldap_hash(self._password_ldap)

220
        ldap_user.mail = self.email
221
222
223
        # Store SSH keys
        ldap_user.sshPublicKey = self.get_ssh_keys()

224
225
        ldap_user.save()

226
227
228
229
        # if creation:
        #     ldap_group = LdapGroup.objects.get(pk='coin')
        #     ldap_group.members.append(ldap_user.pk)
        #     ldap_group.save()
230

231
232
233
234
    def delete_from_ldap(self):
        """
        Delete member from the LDAP
        """
235
        assert self.username, ('Can\'t delete from LDAP because missing '
Fabs's avatar
PEP8    
Fabs committed
236
                               'username value for the Member : %s' % self)
237

Fabs's avatar
PEP8    
Fabs committed
238
        # Delete user from LDAP
239
240
241
        ldap_user = LdapUser.objects.get(pk=self.username)
        ldap_user.delete()

242
        # Lorsqu'un membre est supprimé du SI, son utilisateur LDAP
Baptiste Jonglez's avatar
Baptiste Jonglez committed
243
        # correspondant est sorti du groupe "coin" afin qu'il n'ait plus
244
        # accès au SI
245
246
247
248
        # ldap_group = LdapGroup.objects.get(pk='coin')
        # if self.username in ldap_group.members:
        #     ldap_group.members.remove(self.username)
        #     ldap_group.save()
249

250
251
    def send_welcome_email(self):
        """ Envoie le courriel de bienvenue à ce membre """
252
253
        from coin.isp_database.models import ISPInfo

254
255
256
        utils.send_templated_email(to=self.email,
                                   subject_template='members/emails/welcome_email_subject.txt',
                                   body_template='members/emails/welcome_email.html',
257
                                   context={'member': self, 'branding':ISPInfo.objects.first()})
258

Fabs's avatar
Fabs committed
259
260
261
    class Meta:
        verbose_name = 'membre'

262
# Hack to force email to be required by Member model
263
264
265
266
267
Member._meta.get_field('email')._unique = True
Member._meta.get_field('email').blank = False
Member._meta.get_field('email').null = False


268
269
270
def count_active_members():
    return Member.objects.filter(status='member').count()

Fabs's avatar
PEP8    
Fabs committed
271

272
def get_automatic_username(member):
273
274
275
276
277
    """
    Calcul le username automatiquement en fonction
    du nom et du prénom
    """

278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
    # S'il s'agit d'une entreprise, utilise son nom:
    if member.type == 'legal_entity' and member.organization_name:
        username = member.organization_name
    # Sinon, si un pseudo est définit, l'utilise
    elif member.nickname:
        username = member.nickname
    # Sinon, utilise nom et prenom
    elif member.first_name and member.last_name:
        # Première lettre de chaque partie du prénom
        first_name_letters = ''.join(
            [c[0] for c in member.first_name.split('-')]
        )
        # Concaténer avec nom de famille
        username = ('%s%s' % (first_name_letters, member.last_name))
    else:
        raise Exception('Il n\'y a pas sufissement d\'informations pour déterminer un login automatiquement')

295
296
297
    # Remplacer ou enlever les caractères non ascii
    username = unicodedata.normalize('NFD', username)\
        .encode('ascii', 'ignore')
298
299
    # Enlever ponctuation (sauf _-.) et espace
    punctuation = ('!"#$%&\'()*+,/:;<=>?@[\\]^`{|}~ ').encode('ascii')
300
301
302
303
304
305
306
307
308
309
    username = username.translate(None, punctuation)
    # En minuscule
    username = username.lower()
    # Maximum de 30 char
    username = username[:30]

    # Recherche dans les membres existants un username identique
    member = Member.objects.filter(username=username)
    base_username = username
    incr = 2
310
    # Tant qu'un membre est trouvé, incrémente un entier à la fin
311
312
    while member:
        if len(base_username) >= 30:
Fabs's avatar
PEP8    
Fabs committed
313
            username = base_username[30 - len(str(incr)):]
314
315
316
317
318
319
320
        else:
            username = base_username
        username = username + str(incr)
        member = Member.objects.filter(username=username)
        incr += 1

    return username
321

Fabs's avatar
PEP8    
Fabs committed
322

323
class CryptoKey(CoinLdapSyncMixin, models.Model):
Rogdham's avatar
Rogdham committed
324
325
326

    KEY_TYPE_CHOICES = (('RSA', 'RSA'), ('GPG', 'GPG'))

327
328
    type = models.CharField(max_length=3, choices=KEY_TYPE_CHOICES,
                            verbose_name='type')
329
330
    key = models.TextField(verbose_name='clé')
    member = models.ForeignKey('Member', verbose_name='membre')
Floriane's avatar
Floriane committed
331

332
333
334
335
336
337
338
    def sync_to_ldap(self, creation, *args, **kwargs):
        """Simply tell the member object to resync all its SSH keys to LDAP"""
        self.member.sync_ssh_keys()

    def delete_from_ldap(self, *args, **kwargs):
        self.member.sync_ssh_keys()

Floriane's avatar
Floriane committed
339
    def __unicode__(self):
Baptiste Jonglez's avatar
Baptiste Jonglez committed
340
        return 'Clé %s de %s' % (self.type, self.member)
Floriane's avatar
Floriane committed
341

Fabs's avatar
Fabs committed
342
343
344
    class Meta:
        verbose_name = 'clé'

Fabs's avatar
PEP8    
Fabs committed
345

Fabs's avatar
Fabs committed
346
class MembershipFee(models.Model):
347
348
349
350
351
352
353
    PAYMENT_METHOD_CHOICES = (
        ('cash', 'Espèces'),
        ('check', 'Chèque'),
        ('transfer', 'Virement'),
        ('other', 'Autre')
    )

Fabs's avatar
Fabs committed
354
    member = models.ForeignKey('Member', related_name='membership_fees',
355
                               verbose_name='membre')
356
    amount = models.DecimalField(null=False, max_digits=5, decimal_places=2,
357
                                 default=settings.MEMBER_DEFAULT_COTISATION,
358
                                 verbose_name='montant', help_text='en €')
Fabs's avatar
PEP8    
Fabs committed
359
360
361
    start_date = models.DateField(
        null=False,
        blank=False,
362
        verbose_name='date de début de cotisation')
Fabs's avatar
PEP8    
Fabs committed
363
364
    end_date = models.DateField(
        null=False,
365
366
367
        blank=True,
        verbose_name='date de fin de cotisation',
        help_text='par défaut, la cotisation dure un an')
Floriane's avatar
Floriane committed
368

369
370
371
372
373
374
375
376
377
378
    payment_method = models.CharField(max_length=100, null=True, blank=True,
                                      choices=PAYMENT_METHOD_CHOICES,
                                      verbose_name='moyen de paiement')
    reference = models.CharField(max_length=125, null=True, blank=True,
                                 verbose_name='référence du paiement',
                                 help_text='numéro de chèque, '
                                 'référence de virement, commentaire...')
    payment_date = models.DateField(null=True, blank=True,
                                    verbose_name='date du paiement')

379
380
381
382
    def clean(self):
        if self.end_date is None:
            self.end_date = self.start_date + datetime.timedelta(364)

383
    def __unicode__(self):
Baptiste Jonglez's avatar
Baptiste Jonglez committed
384
        return '%s - %s - %i€' % (self.member, self.start_date, self.amount)
385

Fabs's avatar
Fabs committed
386
387
388
    class Meta:
        verbose_name = 'cotisation'

Floriane's avatar
Floriane committed
389

390
class LdapUser(ldapdb.models.Model):
Fabs's avatar
PEP8    
Fabs committed
391
392
    # "ou=users,ou=unix,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
    base_dn = settings.LDAP_USER_BASE_DN
393
    object_classes = [b'inetOrgPerson', b'organizationalPerson', b'person',
394
                      b'top', b'posixAccount', b'ldapPublicKey']
395

396
397
    uid = CharField(db_column=b'uid', unique=True, max_length=255)
    nick_name = CharField(db_column=b'cn', unique=True, primary_key=True,
398
                          max_length=255)
399
400
401
    first_name = CharField(db_column=b'givenName', max_length=255)
    last_name = CharField(db_column=b'sn', max_length=255)
    display_name = CharField(db_column=b'displayName', max_length=255,
402
                             blank=True)
403
404
405
    password = CharField(db_column=b'userPassword', max_length=255)
    uidNumber = IntegerField(db_column=b'uidNumber', unique=True)
    gidNumber = IntegerField(db_column=b'gidNumber', default=2000)
406
407
408
    # Used by Sympa for logging in.
    mail = CharField(db_column=b'mail', max_length=255, blank=True,
                     unique=True)
409
    homeDirectory = CharField(db_column=b'homeDirectory', max_length=255,
410
                              default='/tmp')
411
412
    loginShell = CharField(db_column=b'loginShell', max_length=255,
                              default='/bin/bash')
413
    sshPublicKey = ListField(db_column=b'sshPublicKey', default=[])
414
415
416
417
418

    def __unicode__(self):
        return self.display_name

    class Meta:
Fabs's avatar
Fabs committed
419
        managed = False  # Indique à Django de ne pas intégrer ce model en base
420

Fabs's avatar
Fabs committed
421

Fabs's avatar
PEP8    
Fabs committed
422
423
424
425
# class LdapGroup(ldapdb.models.Model):
# "ou=groups,ou=unix,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
#     base_dn = settings.LDAP_GROUP_BASE_DN
#     object_classes = [b'posixGroup']
426

Fabs's avatar
PEP8    
Fabs committed
427
428
429
#     gid = IntegerField(db_column=b'gidNumber', unique=True)
#     name = CharField(db_column=b'cn', max_length=200, primary_key=True)
#     members = ListField(db_column=b'memberUid')
430

Fabs's avatar
PEP8    
Fabs committed
431
432
#     def __unicode__(self):
#         return self.name
433

Fabs's avatar
PEP8    
Fabs committed
434
435
#     class Meta:
# managed = False  # Indique à Django de ne pas intégrer ce model en base
436
437


438
@receiver(pre_save, sender=Member)
439
def define_username(sender, instance, **kwargs):
440
    """
441
    Lors de la sauvegarde d'un membre. Si le champ username n'est pas définit,
442
443
    le calcul automatiquement en fonction du nom et du prénom
    """
444
    if not instance.username and not instance.pk:
445
        instance.username = get_automatic_username(instance)
446

Fabs's avatar
PEP8    
Fabs committed
447

448
449
450
451
452
453
454
455
456
@receiver(pre_save, sender=LdapUser)
def define_display_name(sender, instance, **kwargs):
    """
    Lors de la sauvegarde d'un utilisateur Ldap, le champ display_name est la
    concaténation de first_name et last_name
    """
    if not instance.display_name:
        instance.display_name = '%s %s' % (instance.first_name,
                                           instance.last_name)