123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553 |
- # -*- coding: utf-8 -*-
- from __future__ import unicode_literals
- import ldapdb.models
- import unicodedata
- import datetime
- from django.db import models
- from django.db.models import Q, Max
- from django.db.models.signals import pre_save
- from django.dispatch import receiver
- from django.contrib.auth.models import AbstractUser, Permission
- from django.contrib.contenttypes.models import ContentType
- from django.conf import settings
- from django.core.validators import RegexValidator
- from django.core.exceptions import ValidationError
- from django.utils import timezone
- from ldapdb.models.fields import CharField, IntegerField, ListField
- from coin.offers.models import Offer, OfferSubscription
- from coin.mixins import CoinLdapSyncMixin
- from coin import utils
- class Member(CoinLdapSyncMixin, AbstractUser):
- # USERNAME_FIELD = 'login'
- REQUIRED_FIELDS = ['first_name', 'last_name', 'email', ]
- MEMBER_TYPE_CHOICES = (
- ('natural_person', 'Personne physique'),
- ('legal_entity', 'Personne morale'),
- )
- MEMBER_STATUS_CHOICES = (
- ('member', 'Adhérent'),
- ('not_member', 'Non adhérent'),
- ('pending', "Demande d'adhésion"),
- )
- status = models.CharField(max_length=50, choices=MEMBER_STATUS_CHOICES,
- default='member', verbose_name='statut')
- type = models.CharField(max_length=20, choices=MEMBER_TYPE_CHOICES,
- default='natural_person', verbose_name='type')
- nickname = models.CharField(max_length=64, blank=True,
- verbose_name="nom d'usage",
- help_text='Pseudonyme, …')
- organization_name = models.CharField(max_length=200, blank=True,
- verbose_name="nom de l'organisme",
- help_text='Pour une personne morale')
- home_phone_number = models.CharField(max_length=25, blank=True,
- verbose_name='téléphone fixe')
- mobile_phone_number = models.CharField(max_length=25, blank=True,
- verbose_name='téléphone mobile')
- # TODO: use a django module that provides an address model? (would
- # support more countries and address types)
- address = models.TextField(
- verbose_name='adresse postale', blank=True, null=True)
- postal_code = models.CharField(max_length=5, blank=True, null=True,
- validators=[RegexValidator(regex=r'^\d{5}$',
- message='Code postal non valide.')],
- verbose_name='code postal')
- city = models.CharField(max_length=200, blank=True, null=True,
- verbose_name='commune')
- country = models.CharField(max_length=200, blank=True, null=True,
- default='France',
- verbose_name='pays')
- resign_date = models.DateField(null=True, blank=True,
- verbose_name="date de départ de "
- "l'association",
- help_text="En cas de départ prématuré")
- comments = models.TextField(blank=True, verbose_name='commentaires',
- help_text="Commentaires libres (informations"
- " spécifiques concernant l'adhésion,"
- " raison du départ, etc)")
- date_last_call_for_membership_fees_email = models.DateTimeField(null=True,
- blank=True,
- verbose_name="Date du dernier email de relance de cotisation envoyé")
- send_membership_fees_email = models.BooleanField(
- default=True, verbose_name='relance de cotisation',
- help_text='Précise si l\'utilisateur doit recevoir des mails de relance pour la cotisation. Certains membres n\'ont pas à recevoir de relance (prélèvement automatique, membres d\'honneurs, etc.)')
- balance = models.DecimalField(max_digits=5, decimal_places=2, default=0,
- verbose_name='account balance')
- # Following fields are managed by the parent class AbstractUser :
- # username, first_name, last_name, email
- # However we hack the model to force theses fields to be required. (see
- # below)
- # 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
- 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")
- def __unicode__(self):
- if self.type == 'legal_entity':
- return self.organization_name
- elif self.nickname:
- return self.nickname
- else:
- return self.first_name + ' ' + self.last_name
- def get_full_name(self):
- return str(self)
- def get_short_name(self):
- return self.username
- # Renvoie la date de fin de la dernière cotisation du membre
- def end_date_of_membership(self):
- aggregate = self.membership_fees.aggregate(end=Max('end_date'))
- return aggregate['end']
- end_date_of_membership.short_description = "Date de fin d'adhésion"
- def is_paid_up(self, date=None):
- """
- Teste si le membre est à jour de cotisation à la date donnée.
- """
- if date is None:
- date = datetime.date.today()
- end_date = self.end_date_of_membership()
- if end_date is None:
- return False
- return (end_date >= date)
- def set_password(self, new_password, *args, **kwargs):
- """
- Définit le mot de passe a sauvegarder en base et dans le LDAP
- """
- super(Member, self).set_password(new_password, *args, **kwargs)
- self._password_ldap = utils.ldap_hash(new_password)
- def get_active_subscriptions(self, date=None):
- """
- Return list of OfferSubscription which are active today
- """
- if date is None:
- date = datetime.date.today()
- return OfferSubscription.objects.filter(
- Q(member__exact=self.pk),
- Q(subscription_date__lte=date),
- Q(resign_date__isnull=True) | Q(resign_date__gte=date))
- def get_inactive_subscriptions(self, date=None):
- """
- Return list of OfferSubscription which are not active today
- """
- if date is None:
- date = datetime.date.today()
- return OfferSubscription.objects.filter(
- Q(member__exact=self.pk),
- Q(subscription_date__gt=date) |
- Q(resign_date__lt=date))
- def get_ssh_keys(self):
- # Quick & dirty, ensure that keys are unique (otherwise, LDAP complains)
- return list({k.key for k in self.cryptokey_set.filter(type='RSA')})
- 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()
- def sync_to_ldap(self, creation, update_fields, *args, **kwargs):
- """
- Update LDAP data when a member is saved
- """
- # Do not perform LDAP query if no usefull fields to update are specified
- # in update_fields
- # Ex : at login, last_login field is updated by django auth module.
- relevant_fields = {'username', 'last_name', 'first_name',
- 'organization_name', 'email'}
- if update_fields and relevant_fields.isdisjoint(set(update_fields)):
- return
- # Fail if no username specified
- assert self.username, ('Can\'t sync with LDAP because missing username '
- 'value for the Member : %s' % self)
- # 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
- if not creation:
- ldap_user = LdapUser.objects.get(pk=self.username)
- if creation:
- users = LdapUser.objects
- if users.exists():
- uid_number = users.order_by('-uidNumber')[0].uidNumber + 1
- else:
- uid_number = settings.LDAP_USER_FIRST_UID
- ldap_user = LdapUser()
- ldap_user.pk = self.username
- ldap_user.uid = self.username
- ldap_user.nick_name = self.username
- ldap_user.uidNumber = uid_number
- ldap_user.homeDirectory = '/home/' + self.username
- 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 = ""
- # If a password is definied in _password_ldap, change it in LDAP
- if self._password_ldap:
- # Make sure password is hashed
- ldap_user.password = utils.ldap_hash(self._password_ldap)
- ldap_user.mail = self.email
- # Store SSH keys
- ldap_user.sshPublicKey = self.get_ssh_keys()
- ldap_user.save()
- # if creation:
- # ldap_group = LdapGroup.objects.get(pk='coin')
- # ldap_group.members.append(ldap_user.pk)
- # ldap_group.save()
- def delete_from_ldap(self):
- """
- Delete member from the LDAP
- """
- assert self.username, ('Can\'t delete from LDAP because missing '
- 'username value for the Member : %s' % self)
- # Delete user from LDAP
- ldap_user = LdapUser.objects.get(pk=self.username)
- ldap_user.delete()
- # Lorsqu'un membre est supprimé du SI, son utilisateur LDAP
- # correspondant est sorti du groupe "coin" afin qu'il n'ait plus
- # accès au SI
- # ldap_group = LdapGroup.objects.get(pk='coin')
- # if self.username in ldap_group.members:
- # ldap_group.members.remove(self.username)
- # ldap_group.save()
- def send_welcome_email(self):
- """ Envoie le courriel de bienvenue à ce membre """
- from coin.isp_database.models import ISPInfo
- isp_info = ISPInfo.objects.first()
- kwargs = {}
- if isp_info.administrative_email:
- kwargs['from_email'] = isp_info.administrative_email
- utils.send_templated_email(
- to=self.email,
- subject_template='members/emails/welcome_email_subject.txt',
- body_template='members/emails/welcome_email.html',
- context={'member': self, 'branding': isp_info},
- **kwargs)
- def send_call_for_membership_fees_email(self, auto=False):
- """ Envoie le courriel d'appel à cotisation du membre
- :param auto: is it an auto email? (changes slightly template content)
- """
- if auto and not self.send_membership_fees_email:
- return False
- from dateutil.relativedelta import relativedelta
- from coin.isp_database.models import ISPInfo
- isp_info = ISPInfo.objects.first()
- kwargs = {}
- # Il peut ne pas y avir d'ISPInfo, ou bien pas d'administrative_email
- if isp_info and isp_info.administrative_email:
- kwargs['from_email'] = isp_info.administrative_email
- # Si le dernier courriel de relance a été envoyé il y a moins de trois
- # semaines, n'envoi pas un nouveau courriel
- if (not self.date_last_call_for_membership_fees_email
- or (self.date_last_call_for_membership_fees_email
- <= timezone.now() + relativedelta(weeks=-3))):
- utils.send_templated_email(
- to=self.email,
- subject_template='members/emails/call_for_membership_fees_subject.txt',
- body_template='members/emails/call_for_membership_fees.html',
- context={'member': self, 'branding': isp_info,
- 'membership_info_url': settings.MEMBER_MEMBERSHIP_INFO_URL,
- 'today': datetime.date.today,
- 'auto_sent': auto},
- **kwargs)
- # Sauvegarde en base la date du dernier envoi de mail de relance
- self.date_last_call_for_membership_fees_email = timezone.now()
- self.save()
- return True
- return False
- class Meta:
- verbose_name = 'membre'
- # Hack to force email to be required by Member model
- Member._meta.get_field('email')._unique = True
- Member._meta.get_field('email').blank = False
- Member._meta.get_field('email').null = False
- def count_active_members():
- return Member.objects.filter(status='member').count()
- def get_automatic_username(member):
- """
- Calcul le username automatiquement en fonction
- du nom et du prénom
- """
- # 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')
- # Remplacer ou enlever les caractères non ascii
- username = unicodedata.normalize('NFD', username)\
- .encode('ascii', 'ignore')
- # Enlever ponctuation (sauf _-.) et espace
- punctuation = ('!"#$%&\'()*+,/:;<=>?@[\\]^`{|}~ ').encode('ascii')
- 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
- # Tant qu'un membre est trouvé, incrémente un entier à la fin
- while member:
- if len(base_username) >= 30:
- username = base_username[30 - len(str(incr)):]
- else:
- username = base_username
- username = username + str(incr)
- member = Member.objects.filter(username=username)
- incr += 1
- return username
- class CryptoKey(CoinLdapSyncMixin, models.Model):
- KEY_TYPE_CHOICES = (('RSA', 'RSA'), ('GPG', 'GPG'))
- type = models.CharField(max_length=3, choices=KEY_TYPE_CHOICES,
- verbose_name='type')
- key = models.TextField(verbose_name='clé')
- member = models.ForeignKey('Member', verbose_name='membre')
- 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()
- def __unicode__(self):
- return 'Clé %s de %s' % (self.type, self.member)
- class Meta:
- verbose_name = 'clé'
- class MembershipFee(models.Model):
- PAYMENT_METHOD_CHOICES = (
- ('cash', 'Espèces'),
- ('check', 'Chèque'),
- ('transfer', 'Virement'),
- ('other', 'Autre')
- )
- member = models.ForeignKey('Member', related_name='membership_fees',
- verbose_name='membre')
- amount = models.DecimalField(null=False, max_digits=5, decimal_places=2,
- default=settings.MEMBER_DEFAULT_COTISATION,
- verbose_name='montant', help_text='en €')
- start_date = models.DateField(
- null=False,
- blank=False,
- verbose_name='date de début de cotisation')
- end_date = models.DateField(
- null=False,
- blank=True,
- verbose_name='date de fin de cotisation',
- help_text='par défaut, la cotisation dure un an')
- payment_method = models.CharField(max_length=100, null=True, blank=True,
- choices=PAYMENT_METHOD_CHOICES,
- verbose_name='moyen de paiement')
- reference = models.CharField(max_length=125, null=True, blank=True,
- verbose_name='référence du paiement',
- help_text='numéro de chèque, '
- 'référence de virement, commentaire...')
- payment_date = models.DateField(null=True, blank=True,
- verbose_name='date du paiement')
- def clean(self):
- if self.start_date is not None and self.end_date is None:
- self.end_date = self.start_date + datetime.timedelta(364)
- def __unicode__(self):
- return '%s - %s - %i€' % (self.member, self.start_date, self.amount)
- class Meta:
- verbose_name = 'cotisation'
- class LdapUser(ldapdb.models.Model):
- # "ou=users,ou=unix,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
- base_dn = settings.LDAP_USER_BASE_DN
- object_classes = [b'inetOrgPerson', b'organizationalPerson', b'person',
- b'top', b'posixAccount', b'ldapPublicKey']
- uid = CharField(db_column=b'uid', unique=True, max_length=255)
- nick_name = CharField(db_column=b'cn', unique=True, primary_key=True,
- max_length=255)
- first_name = CharField(db_column=b'givenName', max_length=255)
- last_name = CharField(db_column=b'sn', max_length=255)
- display_name = CharField(db_column=b'displayName', max_length=255,
- blank=True)
- password = CharField(db_column=b'userPassword', max_length=255)
- uidNumber = IntegerField(db_column=b'uidNumber', unique=True)
- gidNumber = IntegerField(db_column=b'gidNumber', default=2000)
- # Used by Sympa for logging in.
- mail = CharField(db_column=b'mail', max_length=255, blank=True,
- unique=True)
- homeDirectory = CharField(db_column=b'homeDirectory', max_length=255,
- default='/tmp')
- loginShell = CharField(db_column=b'loginShell', max_length=255,
- default='/bin/bash')
- sshPublicKey = ListField(db_column=b'sshPublicKey', default=[])
- def __unicode__(self):
- return self.display_name
- class Meta:
- managed = False # Indique à Django de ne pas intégrer ce model en base
- # 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']
- # 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')
- # def __unicode__(self):
- # return self.name
- # class Meta:
- # managed = False # Indique à Django de ne pas intégrer ce model en base
- class RowLevelPermission(Permission):
- offer = models.ForeignKey(
- 'offers.Offer', null=True, verbose_name="Offre",
- help_text="Offre dont l'utilisateur est autorisé à voir et modifier les membres et les abonnements.")
- description = models.TextField(blank=True)
- @classmethod
- def get_manageable_offers(cls, user):
- """" Renvoie la liste des offres dont l'utilisateur est autorisé à
- voir les membres et les abonnements dans l'interface d'administration.
- """
- # toutes les permissions appliquées à cet utilisateur
- # (liste de chaines de caractères)
- perms = user.get_all_permissions()
- allowedcodenames = [ s.split('offers.',1)[1] for s in perms if s.startswith('offers.')]
- # parmi toutes les RowLevelpermission, celles qui sont relatives à des OfferSubscription et qui sont dans allowedcodenames
- rowperms = cls.objects.filter(content_type=ContentType.objects.get_for_model(OfferSubscription), codename__in=allowedcodenames)
- # toutes les Offers pour lesquelles il existe une RowLevelpermission correspondante dans rowperms
- return Offer.objects.filter(rowlevelpermission__in=rowperms).distinct()
- @classmethod
- def get_manageable_users(cls, user):
- """" Renvoie la liste des members que l'utilisateur est autorisé à voir
- dans l'interface d'administration.
- """
- if user.is_superuser:
- return Member.objects.all()
- else:
- offers = RowLevelPermission.get_manageable_offers(user)
- return Member.objects.filter(offersubscription__offer__in=offers).distinct()
- class Meta:
- verbose_name = 'permission fine'
- verbose_name_plural = 'permissions fines'
- @receiver(pre_save, sender=Member)
- def define_username(sender, instance, **kwargs):
- """
- Lors de la sauvegarde d'un membre. Si le champ username n'est pas définit,
- le calcul automatiquement en fonction du nom et du prénom
- """
- if not instance.username and not instance.pk:
- instance.username = get_automatic_username(instance)
- @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)
|