models.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. # -*- coding: utf-8 -*-
  2. from __future__ import unicode_literals
  3. import ldapdb.models
  4. import unicodedata
  5. import datetime
  6. from django.db import models
  7. from django.db.models import Q, Max
  8. from django.db.models.signals import pre_save
  9. from django.dispatch import receiver
  10. from django.contrib.auth.models import AbstractUser
  11. from django.conf import settings
  12. from django.core.validators import RegexValidator
  13. from django.core.exceptions import ValidationError
  14. from django.utils import timezone
  15. from ldapdb.models.fields import CharField, IntegerField, ListField
  16. from coin.offers.models import OfferSubscription
  17. from coin.mixins import CoinLdapSyncMixin
  18. from coin import utils
  19. class Member(CoinLdapSyncMixin, AbstractUser):
  20. # USERNAME_FIELD = 'login'
  21. REQUIRED_FIELDS = ['first_name', 'last_name', 'email', ]
  22. MEMBER_TYPE_CHOICES = (
  23. ('natural_person', 'Personne physique'),
  24. ('legal_entity', 'Personne morale'),
  25. )
  26. MEMBER_STATUS_CHOICES = (
  27. ('member', 'Adhérent'),
  28. ('not_member', 'Non adhérent'),
  29. ('pending', "Demande d'adhésion"),
  30. )
  31. status = models.CharField(max_length=50, choices=MEMBER_STATUS_CHOICES,
  32. default='member', verbose_name='statut')
  33. type = models.CharField(max_length=20, choices=MEMBER_TYPE_CHOICES,
  34. default='natural_person', verbose_name='type')
  35. nickname = models.CharField(max_length=64, blank=True,
  36. verbose_name="nom d'usage",
  37. help_text='Pseudonyme, …')
  38. organization_name = models.CharField(max_length=200, blank=True,
  39. verbose_name="nom de l'organisme",
  40. help_text='Pour une personne morale')
  41. home_phone_number = models.CharField(max_length=25, blank=True,
  42. verbose_name='téléphone fixe')
  43. mobile_phone_number = models.CharField(max_length=25, blank=True,
  44. verbose_name='téléphone mobile')
  45. # TODO: use a django module that provides an address model? (would
  46. # support more countries and address types)
  47. address = models.TextField(
  48. verbose_name='adresse postale', blank=True, null=True)
  49. postal_code = models.CharField(max_length=5, blank=True, null=True,
  50. validators=[RegexValidator(regex=r'^\d{5}$',
  51. message='Code postal non valide.')],
  52. verbose_name='code postal')
  53. city = models.CharField(max_length=200, blank=True, null=True,
  54. verbose_name='commune')
  55. country = models.CharField(max_length=200, blank=True, null=True,
  56. default='France',
  57. verbose_name='pays')
  58. resign_date = models.DateField(null=True, blank=True,
  59. verbose_name="date de départ de "
  60. "l'association",
  61. help_text="En cas de départ prématuré")
  62. comments = models.TextField(blank=True, verbose_name='commentaires',
  63. help_text="Commentaires libres (informations"
  64. " spécifiques concernant l'adhésion,"
  65. " raison du départ, etc)")
  66. date_last_call_for_membership_fees_email = models.DateTimeField(null=True,
  67. blank=True,
  68. verbose_name="Date du dernier email de relance de cotisation envoyé")
  69. # Following fields are managed by the parent class AbstractUser :
  70. # username, first_name, last_name, email
  71. # However we hack the model to force theses fields to be required. (see
  72. # below)
  73. # This property is used to change password in LDAP. Used in sync_to_ldap.
  74. # Should not be defined manually. Prefer use set_password method that hash
  75. # passwords for both ldap and local db
  76. _password_ldap = None
  77. def clean(self):
  78. if self.type == 'legal_entity':
  79. if not self.organization_name:
  80. raise ValidationError("Le nom de l'organisme est obligatoire "
  81. "pour une personne morale")
  82. elif self.type == 'natural_person':
  83. if not (self.first_name and self.last_name):
  84. raise ValidationError("Le nom et prénom sont obligatoires "
  85. "pour une personne physique")
  86. def __unicode__(self):
  87. if self.type == 'legal_entity':
  88. return self.organization_name
  89. elif self.nickname:
  90. return self.nickname
  91. else:
  92. return self.first_name + ' ' + self.last_name
  93. def get_full_name(self):
  94. return str(self)
  95. def get_short_name(self):
  96. return self.username
  97. # Renvoie la date de fin de la dernière cotisation du membre
  98. def end_date_of_membership(self):
  99. aggregate = self.membership_fees.aggregate(end=Max('end_date'))
  100. return aggregate['end']
  101. end_date_of_membership.short_description = "Date de fin d'adhésion"
  102. def is_paid_up(self, date=None):
  103. """
  104. Teste si le membre est à jour de cotisation à la date donnée.
  105. """
  106. if date is None:
  107. date = datetime.date.today()
  108. end_date = self.end_date_of_membership()
  109. if end_date is None:
  110. return False
  111. return (end_date >= date)
  112. def set_password(self, new_password, *args, **kwargs):
  113. """
  114. Définit le mot de passe a sauvegarder en base et dans le LDAP
  115. """
  116. super(Member, self).set_password(new_password, *args, **kwargs)
  117. self._password_ldap = utils.ldap_hash(new_password)
  118. def get_active_subscriptions(self, date=None):
  119. """
  120. Return list of OfferSubscription which are active today
  121. """
  122. if date is None:
  123. date = datetime.date.today()
  124. return OfferSubscription.objects.filter(
  125. Q(member__exact=self.pk),
  126. Q(subscription_date__lte=date),
  127. Q(resign_date__isnull=True) | Q(resign_date__gte=date))
  128. def get_inactive_subscriptions(self, date=None):
  129. """
  130. Return list of OfferSubscription which are not active today
  131. """
  132. if date is None:
  133. date = datetime.date.today()
  134. return OfferSubscription.objects.filter(
  135. Q(member__exact=self.pk),
  136. Q(subscription_date__gt=date) |
  137. Q(resign_date__lt=date))
  138. def get_ssh_keys(self):
  139. # Quick & dirty, ensure that keys are unique (otherwise, LDAP complains)
  140. return list({k.key for k in self.cryptokey_set.filter(type='RSA')})
  141. def sync_ssh_keys(self):
  142. """
  143. Called whenever a SSH key is saved
  144. """
  145. ldap_user = LdapUser.objects.get(pk=self.username)
  146. ldap_user.sshPublicKey = self.get_ssh_keys()
  147. ldap_user.save()
  148. def sync_to_ldap(self, creation, update_fields, *args, **kwargs):
  149. """
  150. Update LDAP data when a member is saved
  151. """
  152. # Do not perform LDAP query if no usefull fields to update are specified
  153. # in update_fields
  154. # Ex : at login, last_login field is updated by django auth module.
  155. relevant_fields = {'username', 'last_name', 'first_name',
  156. 'organization_name', 'email'}
  157. if update_fields and relevant_fields.isdisjoint(set(update_fields)):
  158. return
  159. # Fail if no username specified
  160. assert self.username, ('Can\'t sync with LDAP because missing username '
  161. 'value for the Member : %s' % self)
  162. # If try to sync a superuser in creation mode
  163. # Try to retrieve the user in ldap. If exists, switch to update mode
  164. # This allow to create a superuser without having to delete corresponding
  165. # username in LDAP
  166. if self.is_superuser and creation:
  167. try:
  168. ldap_user = LdapUser.objects.get(pk=self.username)
  169. creation = False
  170. except LdapUser.DoesNotExist:
  171. pass
  172. if not creation:
  173. ldap_user = LdapUser.objects.get(pk=self.username)
  174. if creation:
  175. users = LdapUser.objects
  176. if users.exists():
  177. uid_number = users.order_by('-uidNumber')[0].uidNumber + 1
  178. else:
  179. uid_number = settings.LDAP_USER_FIRST_UID
  180. ldap_user = LdapUser()
  181. ldap_user.pk = self.username
  182. ldap_user.uid = self.username
  183. ldap_user.nick_name = self.username
  184. ldap_user.uidNumber = uid_number
  185. ldap_user.homeDirectory = '/home/' + self.username
  186. if self.type == 'natural_person':
  187. ldap_user.last_name = self.last_name
  188. ldap_user.first_name = self.first_name
  189. elif self.type == 'legal_entity':
  190. ldap_user.last_name = self.organization_name
  191. ldap_user.first_name = ""
  192. # If a password is definied in _password_ldap, change it in LDAP
  193. if self._password_ldap:
  194. # Make sure password is hashed
  195. ldap_user.password = utils.ldap_hash(self._password_ldap)
  196. ldap_user.mail = self.email
  197. # Store SSH keys
  198. ldap_user.sshPublicKey = self.get_ssh_keys()
  199. ldap_user.save()
  200. # if creation:
  201. # ldap_group = LdapGroup.objects.get(pk='coin')
  202. # ldap_group.members.append(ldap_user.pk)
  203. # ldap_group.save()
  204. def delete_from_ldap(self):
  205. """
  206. Delete member from the LDAP
  207. """
  208. assert self.username, ('Can\'t delete from LDAP because missing '
  209. 'username value for the Member : %s' % self)
  210. # Delete user from LDAP
  211. ldap_user = LdapUser.objects.get(pk=self.username)
  212. ldap_user.delete()
  213. # Lorsqu'un membre est supprimé du SI, son utilisateur LDAP
  214. # correspondant est sorti du groupe "coin" afin qu'il n'ait plus
  215. # accès au SI
  216. # ldap_group = LdapGroup.objects.get(pk='coin')
  217. # if self.username in ldap_group.members:
  218. # ldap_group.members.remove(self.username)
  219. # ldap_group.save()
  220. def send_welcome_email(self):
  221. """ Envoie le courriel de bienvenue à ce membre """
  222. from coin.isp_database.models import ISPInfo
  223. isp_info = ISPInfo.objects.first()
  224. kwargs = {}
  225. if isp_info.administrative_email:
  226. kwargs['from_email'] = isp_info.administrative_email
  227. utils.send_templated_email(
  228. to=self.email,
  229. subject_template='members/emails/welcome_email_subject.txt',
  230. body_template='members/emails/welcome_email.html',
  231. context={'member': self, 'branding': isp_info},
  232. **kwargs)
  233. def send_call_for_membership_fees_email(self, auto=False):
  234. """ Envoie le courriel d'appel à cotisation du membre
  235. :param auto: is it an auto email? (changes slightly template content)
  236. """
  237. from dateutil.relativedelta import relativedelta
  238. from coin.isp_database.models import ISPInfo
  239. isp_info = ISPInfo.objects.first()
  240. kwargs = {}
  241. if isp_info.administrative_email:
  242. kwargs['from_email'] = isp_info.administrative_email
  243. # Si le dernier courriel de relance a été envoyé il y a moins de trois
  244. # semaines, n'envoi pas un nouveau courriel
  245. if (not self.date_last_call_for_membership_fees_email
  246. or (self.date_last_call_for_membership_fees_email
  247. <= timezone.now() + relativedelta(weeks=-3))):
  248. utils.send_templated_email(
  249. to=self.email,
  250. subject_template='members/emails/call_for_membership_fees_subject.txt',
  251. body_template='members/emails/call_for_membership_fees.html',
  252. context={'member': self, 'branding': isp_info,
  253. 'membership_info_url': settings.MEMBER_MEMBERSHIP_INFO_URL,
  254. 'today': datetime.date.today,
  255. 'auto_sent': auto},
  256. **kwargs)
  257. # Sauvegarde en base la date du dernier envoi de mail de relance
  258. self.date_last_call_for_membership_fees_email = timezone.now()
  259. self.save()
  260. return True
  261. return False
  262. class Meta:
  263. verbose_name = 'membre'
  264. # Hack to force email to be required by Member model
  265. Member._meta.get_field('email')._unique = True
  266. Member._meta.get_field('email').blank = False
  267. Member._meta.get_field('email').null = False
  268. def count_active_members():
  269. return Member.objects.filter(status='member').count()
  270. def get_automatic_username(member):
  271. """
  272. Calcul le username automatiquement en fonction
  273. du nom et du prénom
  274. """
  275. # S'il s'agit d'une entreprise, utilise son nom:
  276. if member.type == 'legal_entity' and member.organization_name:
  277. username = member.organization_name
  278. # Sinon, si un pseudo est définit, l'utilise
  279. elif member.nickname:
  280. username = member.nickname
  281. # Sinon, utilise nom et prenom
  282. elif member.first_name and member.last_name:
  283. # Première lettre de chaque partie du prénom
  284. first_name_letters = ''.join(
  285. [c[0] for c in member.first_name.split('-')]
  286. )
  287. # Concaténer avec nom de famille
  288. username = ('%s%s' % (first_name_letters, member.last_name))
  289. else:
  290. raise Exception('Il n\'y a pas sufissement d\'informations pour déterminer un login automatiquement')
  291. # Remplacer ou enlever les caractères non ascii
  292. username = unicodedata.normalize('NFD', username)\
  293. .encode('ascii', 'ignore')
  294. # Enlever ponctuation (sauf _-.) et espace
  295. punctuation = ('!"#$%&\'()*+,/:;<=>?@[\\]^`{|}~ ').encode('ascii')
  296. username = username.translate(None, punctuation)
  297. # En minuscule
  298. username = username.lower()
  299. # Maximum de 30 char
  300. username = username[:30]
  301. # Recherche dans les membres existants un username identique
  302. member = Member.objects.filter(username=username)
  303. base_username = username
  304. incr = 2
  305. # Tant qu'un membre est trouvé, incrémente un entier à la fin
  306. while member:
  307. if len(base_username) >= 30:
  308. username = base_username[30 - len(str(incr)):]
  309. else:
  310. username = base_username
  311. username = username + str(incr)
  312. member = Member.objects.filter(username=username)
  313. incr += 1
  314. return username
  315. class CryptoKey(CoinLdapSyncMixin, models.Model):
  316. KEY_TYPE_CHOICES = (('RSA', 'RSA'), ('GPG', 'GPG'))
  317. type = models.CharField(max_length=3, choices=KEY_TYPE_CHOICES,
  318. verbose_name='type')
  319. key = models.TextField(verbose_name='clé')
  320. member = models.ForeignKey('Member', verbose_name='membre')
  321. def sync_to_ldap(self, creation, *args, **kwargs):
  322. """Simply tell the member object to resync all its SSH keys to LDAP"""
  323. self.member.sync_ssh_keys()
  324. def delete_from_ldap(self, *args, **kwargs):
  325. self.member.sync_ssh_keys()
  326. def __unicode__(self):
  327. return 'Clé %s de %s' % (self.type, self.member)
  328. class Meta:
  329. verbose_name = 'clé'
  330. class MembershipFee(models.Model):
  331. PAYMENT_METHOD_CHOICES = (
  332. ('cash', 'Espèces'),
  333. ('check', 'Chèque'),
  334. ('transfer', 'Virement'),
  335. ('other', 'Autre')
  336. )
  337. member = models.ForeignKey('Member', related_name='membership_fees',
  338. verbose_name='membre')
  339. amount = models.DecimalField(null=False, max_digits=5, decimal_places=2,
  340. default=settings.MEMBER_DEFAULT_COTISATION,
  341. verbose_name='montant', help_text='en €')
  342. start_date = models.DateField(
  343. null=False,
  344. blank=False,
  345. verbose_name='date de début de cotisation')
  346. end_date = models.DateField(
  347. null=False,
  348. blank=True,
  349. verbose_name='date de fin de cotisation',
  350. help_text='par défaut, la cotisation dure un an')
  351. payment_method = models.CharField(max_length=100, null=True, blank=True,
  352. choices=PAYMENT_METHOD_CHOICES,
  353. verbose_name='moyen de paiement')
  354. reference = models.CharField(max_length=125, null=True, blank=True,
  355. verbose_name='référence du paiement',
  356. help_text='numéro de chèque, '
  357. 'référence de virement, commentaire...')
  358. payment_date = models.DateField(null=True, blank=True,
  359. verbose_name='date du paiement')
  360. def clean(self):
  361. if self.end_date is None:
  362. self.end_date = self.start_date + datetime.timedelta(364)
  363. def __unicode__(self):
  364. return '%s - %s - %i€' % (self.member, self.start_date, self.amount)
  365. class Meta:
  366. verbose_name = 'cotisation'
  367. class LdapUser(ldapdb.models.Model):
  368. # "ou=users,ou=unix,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
  369. base_dn = settings.LDAP_USER_BASE_DN
  370. object_classes = [b'inetOrgPerson', b'organizationalPerson', b'person',
  371. b'top', b'posixAccount', b'ldapPublicKey']
  372. uid = CharField(db_column=b'uid', unique=True, max_length=255)
  373. nick_name = CharField(db_column=b'cn', unique=True, primary_key=True,
  374. max_length=255)
  375. first_name = CharField(db_column=b'givenName', max_length=255)
  376. last_name = CharField(db_column=b'sn', max_length=255)
  377. display_name = CharField(db_column=b'displayName', max_length=255,
  378. blank=True)
  379. password = CharField(db_column=b'userPassword', max_length=255)
  380. uidNumber = IntegerField(db_column=b'uidNumber', unique=True)
  381. gidNumber = IntegerField(db_column=b'gidNumber', default=2000)
  382. # Used by Sympa for logging in.
  383. mail = CharField(db_column=b'mail', max_length=255, blank=True,
  384. unique=True)
  385. homeDirectory = CharField(db_column=b'homeDirectory', max_length=255,
  386. default='/tmp')
  387. loginShell = CharField(db_column=b'loginShell', max_length=255,
  388. default='/bin/bash')
  389. sshPublicKey = ListField(db_column=b'sshPublicKey', default=[])
  390. def __unicode__(self):
  391. return self.display_name
  392. class Meta:
  393. managed = False # Indique à Django de ne pas intégrer ce model en base
  394. # class LdapGroup(ldapdb.models.Model):
  395. # "ou=groups,ou=unix,o=ILLYSE,l=Villeurbanne,st=RHA,c=FR"
  396. # base_dn = settings.LDAP_GROUP_BASE_DN
  397. # object_classes = [b'posixGroup']
  398. # gid = IntegerField(db_column=b'gidNumber', unique=True)
  399. # name = CharField(db_column=b'cn', max_length=200, primary_key=True)
  400. # members = ListField(db_column=b'memberUid')
  401. # def __unicode__(self):
  402. # return self.name
  403. # class Meta:
  404. # managed = False # Indique à Django de ne pas intégrer ce model en base
  405. @receiver(pre_save, sender=Member)
  406. def define_username(sender, instance, **kwargs):
  407. """
  408. Lors de la sauvegarde d'un membre. Si le champ username n'est pas définit,
  409. le calcul automatiquement en fonction du nom et du prénom
  410. """
  411. if not instance.username and not instance.pk:
  412. instance.username = get_automatic_username(instance)
  413. @receiver(pre_save, sender=LdapUser)
  414. def define_display_name(sender, instance, **kwargs):
  415. """
  416. Lors de la sauvegarde d'un utilisateur Ldap, le champ display_name est la
  417. concaténation de first_name et last_name
  418. """
  419. if not instance.display_name:
  420. instance.display_name = '%s %s' % (instance.first_name,
  421. instance.last_name)