123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641 |
- # -*- 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, UserManager
- from django.conf import settings
- from django.core.validators import RegexValidator
- from django.core.exceptions import ValidationError
- from django.utils import timezone
- from django.utils.text import slugify
- from django.core.mail import send_mail
- from django.core.urlresolvers import reverse
- from ldapdb.models.fields import CharField, IntegerField, ListField
- from registration.signals import user_registered
- from coin.offers.models import Offer, OfferSubscription
- from coin.mixins import CoinLdapSyncMixin
- from coin import utils
- class MemberQuerySet(models.QuerySet):
- paidup_q = Q(
- # we have at least one fee
- membership_fees__isnull=False,
- # and it is still running
- membership_fees__end_date__gte=datetime.date.today)
- def paidup_fee(self):
- return self.filter(self.paidup_q)
- def no_fee_or_late(self):
- return self.exclude(self.paidup_q)
- def could_be_deleted(self):
- return self.exclude(
- # we have at least one subscription
- Q(offersubscription__isnull=False),
- # still running or resigned less than one year ago
- Q(offersubscription__resign_date__isnull=True)
- |
- Q(offersubscription__resign_date__gte=utils.one_year_ago())
- ).exclude(self.paidup_q)
- class MemberManager(UserManager):
- use_in_migrations = False
- def manageable_by(self, user):
- """" Renvoie la liste des members que l'utilisateur est autorisé à voir
- dans l'interface d'administration.
- """
- if user.is_superuser:
- return super(MemberManager, self).all()
- else:
- offers = Offer.objects.manageable_by(user)
- return super(MemberManager, self).filter(offersubscription__offer__in=offers).distinct()
- @receiver(user_registered)
- def send_registration_notification(sender, user, request=None, **kwargs):
- """
- Send a notification to the admin if a user subscribe
- """
- relative_link = reverse('admin:members_member_change', args=[user.id])
- edit_link = request.build_absolute_uri(relative_link)
- utils.send_templated_email(
- to=settings.NOTIFICATION_EMAILS,
- subject_template='members/emails/new_member_subject.txt',
- body_template='members/emails/new_member_email.html',
- context={'member': self, 'edit_link': edit_link},
- **kwargs)
- 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_MEMBER = 'member'
- MEMBER_STATUS_NOT_MEMBER = 'not_member'
- MEMBER_STATUS_PENDING = 'pending'
- MEMBER_STATUS_CHOICES = (
- (MEMBER_STATUS_MEMBER, 'Adhérent'),
- (MEMBER_STATUS_NOT_MEMBER, 'Non adhérent'),
- (MEMBER_STATUS_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='solde')
- objects = MemberManager.from_queryset(MemberQuerySet)()
- # 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_recent_inactive_subscriptions(self):
- """ Does this member has subscriptions that ended less than one year ago
- For French law requirements.
- """
- return self.get_inactive_subscriptions().filter(
- resign_date__gte=datetime.date.today() - datetime.timedelta(days=365))
- 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.DEFAULT_MEMBERSHIP_FEE,
- 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 save(self, *args, **kwargs):
- ret = super(MembershipFee, self).save(*args, **kwargs)
- today = datetime.date.today()
- if self.start_date <= today and today <= self.end_date:
- self.member.status = self.member.MEMBER_STATUS_MEMBER
- self.member.save()
- return ret
- 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
- @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)
- 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)
- def save(self, *args, **kwargs):
- """
- Lors de la sauvegarde d'une RowLevelPermission. Si le champ codename n'est pas définit,
- le calcul automatiquement.
- """
- if not self.codename:
- self.codename = self.generate_codename()
- return super(RowLevelPermission, self).save(*args, **kwargs)
- def generate_codename(self):
- """
- Calcule le codename automatiquement en fonction du name.
- """
- # Convertit en ASCII. Convertit les espaces en tirets. Enlève les caractères qui ne sont ni alphanumériques, ni soulignements, ni tirets. Convertit en minuscules. Les espaces en début et fin de chaîne sont aussi enlevés
- codename = slugify(self.name)
- # Maximum de 30 char
- codename = codename[:30]
- # Recherche dans les membres existants un codename identique
- perm = Permission.objects.filter(codename=codename)
- base_codename = codename
- incr = 2
- # Tant qu'une permission est trouvée, incrémente un entier à la fin
- while perm:
- codename = base_codename + str(incr)
- perm = Permission.objects.filter(codename=codename)
- incr += 1
- return codename
- class Meta:
- verbose_name = 'permission fine'
- verbose_name_plural = 'permissions fines'
- RowLevelPermission._meta.get_field('codename').blank = True
- RowLevelPermission._meta.get_field('codename').help_text = 'Laisser vide pour le générer automatiquement'
- RowLevelPermission._meta.get_field('content_type').help_text = "Garder 'abonnement' pour une utilisation normale"
|