AstakosUser signed_terms property instead of function
[astakos] / snf-astakos-app / astakos / im / models.py
1 # Copyright 2011-2012 GRNET S.A. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
5 # conditions are met:
6 #
7 #   1. Redistributions of source code must retain the above
8 #      copyright notice, this list of conditions and the following
9 #      disclaimer.
10 #
11 #   2. Redistributions in binary form must reproduce the above
12 #      copyright notice, this list of conditions and the following
13 #      disclaimer in the documentation and/or other materials
14 #      provided with the distribution.
15 #
16 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27 # POSSIBILITY OF SUCH DAMAGE.
28 #
29 # The views and conclusions contained in the software and
30 # documentation are those of the authors and should not be
31 # interpreted as representing official policies, either expressed
32 # or implied, of GRNET S.A.
33
34 import hashlib
35 import uuid
36 import logging
37
38 from time import asctime
39 from datetime import datetime, timedelta
40 from base64 import b64encode
41 from random import randint
42 from collections import defaultdict
43
44 from django.db import models
45 from django.contrib.auth.models import User, UserManager, Group
46 from django.utils.translation import ugettext as _
47 from django.core.exceptions import ValidationError
48 from django.db import transaction
49 from django.db.models.signals import pre_save, post_save, post_syncdb, post_delete
50 from django.dispatch import Signal
51 from django.db.models import Q
52
53 from astakos.im.settings import (DEFAULT_USER_LEVEL, INVITATIONS_PER_LEVEL,
54     AUTH_TOKEN_DURATION, BILLING_FIELDS, EMAILCHANGE_ACTIVATION_DAYS, LOGGING_LEVEL
55 )
56 from astakos.im.endpoints.quotaholder import register_users, send_quota
57 from astakos.im.endpoints.aquarium.producer import report_user_event
58
59 from astakos.im.tasks import propagate_groupmembers_quota
60
61 logger = logging.getLogger(__name__)
62
63 class Service(models.Model):
64     name = models.CharField('Name', max_length=255, unique=True, db_index=True)
65     url = models.FilePathField()
66     icon = models.FilePathField(blank=True)
67     auth_token = models.CharField('Authentication Token', max_length=32,
68                                   null=True, blank=True)
69     auth_token_created = models.DateTimeField('Token creation date', null=True)
70     auth_token_expires = models.DateTimeField('Token expiration date', null=True)
71     
72     def save(self, **kwargs):
73         if not self.id:
74             self.renew_token()
75         self.full_clean()
76         super(Service, self).save(**kwargs)
77     
78     def renew_token(self):
79         md5 = hashlib.md5()
80         md5.update(self.name.encode('ascii', 'ignore'))
81         md5.update(self.url.encode('ascii', 'ignore'))
82         md5.update(asctime())
83
84         self.auth_token = b64encode(md5.digest())
85         self.auth_token_created = datetime.now()
86         self.auth_token_expires = self.auth_token_created + \
87                                   timedelta(hours=AUTH_TOKEN_DURATION)
88     
89     def __str__(self):
90         return self.name
91
92 class ResourceMetadata(models.Model):
93     key = models.CharField('Name', max_length=255, unique=True, db_index=True)
94     value = models.CharField('Value', max_length=255)
95
96 class Resource(models.Model):
97     name = models.CharField('Name', max_length=255, unique=True, db_index=True)
98     meta = models.ManyToManyField(ResourceMetadata)
99     service = models.ForeignKey(Service)
100     
101     def __str__(self):
102         return '%s : %s' % (self.service, self.name)
103
104 class GroupKind(models.Model):
105     name = models.CharField('Name', max_length=255, unique=True, db_index=True)
106     
107     def __str__(self):
108         return self.name
109
110 class AstakosGroup(Group):
111     kind = models.ForeignKey(GroupKind)
112     desc = models.TextField('Description', null=True)
113     policy = models.ManyToManyField(Resource, null=True, blank=True,
114         through='AstakosGroupQuota'
115     )
116     creation_date = models.DateTimeField('Creation date',
117         default=datetime.now()
118     )
119     issue_date = models.DateTimeField('Issue date', null=True)
120     expiration_date = models.DateTimeField('Expiration date', null=True)
121     moderation_enabled = models.BooleanField('Moderated membership?',
122         default=True
123     )
124     approval_date = models.DateTimeField('Activation date', null=True,
125         blank=True
126     )
127     estimated_participants = models.PositiveIntegerField('Estimated #members',
128         null=True
129     )
130     
131     @property
132     def is_disabled(self):
133         if not self.approval_date:
134             return True
135         return False
136     
137     @property
138     def is_enabled(self):
139         if self.is_disabled:
140             return False
141         if not self.issue_date:
142             return False
143         if not self.expiration_date:
144             return True
145         now = datetime.now()
146         if self.issue_date > now:
147             return False
148         if now >= self.expiration_date:
149             return False
150         return True
151     
152     def enable(self):
153         if self.is_enabled:
154             return
155         self.approval_date = datetime.now()
156         self.save()
157         quota_disturbed.send(sender=self, users=self.approved_members)
158         propagate_groupmembers_quota.apply_async(args=[self], eta=self.issue_date)
159         propagate_groupmembers_quota.apply_async(args=[self], eta=self.expiration_date)
160     
161     def disable(self):
162         if self.is_disabled:
163             return
164         self.approval_date = None
165         self.save()
166         quota_disturbed.send(sender=self, users=self.approved_members)
167     
168     def approve_member(self, person):
169         m, created = self.membership_set.get_or_create(person=person)
170         # update date_joined in any case
171         m.date_joined=datetime.now()
172         m.save()
173     
174     def disapprove_member(self, person):
175         self.membership_set.remove(person=person)
176     
177     @property
178     def members(self):
179         return [m.person for m in self.membership_set.all()]
180     
181     @property
182     def approved_members(self):
183         return [m.person for m in self.membership_set.all() if m.is_approved]
184     
185     @property
186     def quota(self):
187         d = defaultdict(int)
188         for q in self.astakosgroupquota_set.all():
189             d[q.resource] += q.limit
190         return d
191     
192     @property
193     def owners(self):
194         return self.owner.all()
195     
196     @owners.setter
197     def owners(self, l):
198         self.owner = l
199         map(self.approve_member, l)
200
201 class AstakosUser(User):
202     """
203     Extends ``django.contrib.auth.models.User`` by defining additional fields.
204     """
205     # Use UserManager to get the create_user method, etc.
206     objects = UserManager()
207
208     affiliation = models.CharField('Affiliation', max_length=255, blank=True)
209     provider = models.CharField('Provider', max_length=255, blank=True)
210
211     #for invitations
212     user_level = DEFAULT_USER_LEVEL
213     level = models.IntegerField('Inviter level', default=user_level)
214     invitations = models.IntegerField('Invitations left', default=INVITATIONS_PER_LEVEL.get(user_level, 0))
215
216     auth_token = models.CharField('Authentication Token', max_length=32,
217                                   null=True, blank=True)
218     auth_token_created = models.DateTimeField('Token creation date', null=True)
219     auth_token_expires = models.DateTimeField('Token expiration date', null=True)
220
221     updated = models.DateTimeField('Update date')
222     is_verified = models.BooleanField('Is verified?', default=False)
223
224     # ex. screen_name for twitter, eppn for shibboleth
225     third_party_identifier = models.CharField('Third-party identifier', max_length=255, null=True, blank=True)
226
227     email_verified = models.BooleanField('Email verified?', default=False)
228
229     has_credits = models.BooleanField('Has credits?', default=False)
230     has_signed_terms = models.BooleanField('Agree with the terms?', default=False)
231     date_signed_terms = models.DateTimeField('Signed terms date', null=True, blank=True)
232     
233     activation_sent = models.DateTimeField('Activation sent data', null=True, blank=True)
234     
235     policy = models.ManyToManyField(Resource, null=True, through='AstakosUserQuota')
236     
237     astakos_groups = models.ManyToManyField(AstakosGroup, verbose_name=_('agroups'), blank=True,
238         help_text=_("In addition to the permissions manually assigned, this user will also get all permissions granted to each group he/she is in."),
239         through='Membership')
240     
241     __has_signed_terms = False
242     
243     owner = models.ManyToManyField(AstakosGroup, related_name='owner', null=True)
244     
245     class Meta:
246         unique_together = ("provider", "third_party_identifier")
247     
248     def __init__(self, *args, **kwargs):
249         super(AstakosUser, self).__init__(*args, **kwargs)
250         self.__has_signed_terms = self.has_signed_terms
251         if not self.id:
252             self.is_active = False
253     
254     @property
255     def realname(self):
256         return '%s %s' %(self.first_name, self.last_name)
257
258     @realname.setter
259     def realname(self, value):
260         parts = value.split(' ')
261         if len(parts) == 2:
262             self.first_name = parts[0]
263             self.last_name = parts[1]
264         else:
265             self.last_name = parts[0]
266
267     @property
268     def invitation(self):
269         try:
270             return Invitation.objects.get(username=self.email)
271         except Invitation.DoesNotExist:
272             return None
273     
274     @property
275     def quota(self):
276         d = defaultdict(int)
277         for q in  self.astakosuserquota_set.all():
278             d[q.resource.name] += q.limit
279         for m in self.membership_set.all():
280             if not m.is_approved:
281                 continue
282             g = m.group
283             if not g.is_enabled:
284                 continue
285             for r, limit in g.quota.iteritems():
286                 d[r] += limit
287         # TODO set default for remaining
288         return d
289         
290     def save(self, update_timestamps=True, **kwargs):
291         if update_timestamps:
292             if not self.id:
293                 self.date_joined = datetime.now()
294             self.updated = datetime.now()
295         
296         # update date_signed_terms if necessary
297         if self.__has_signed_terms != self.has_signed_terms:
298             self.date_signed_terms = datetime.now()
299         
300         if not self.id:
301             # set username
302             while not self.username:
303                 username =  uuid.uuid4().hex[:30]
304                 try:
305                     AstakosUser.objects.get(username = username)
306                 except AstakosUser.DoesNotExist:
307                     self.username = username
308             if not self.provider:
309                 self.provider = 'local'
310         self.validate_unique_email_isactive()
311         if self.is_active and self.activation_sent:
312             # reset the activation sent
313             self.activation_sent = None
314         
315         super(AstakosUser, self).save(**kwargs)
316     
317     def renew_token(self):
318         md5 = hashlib.md5()
319         md5.update(self.username)
320         md5.update(self.realname.encode('ascii', 'ignore'))
321         md5.update(asctime())
322
323         self.auth_token = b64encode(md5.digest())
324         self.auth_token_created = datetime.now()
325         self.auth_token_expires = self.auth_token_created + \
326                                   timedelta(hours=AUTH_TOKEN_DURATION)
327         msg = 'Token renewed for %s' % self.email
328         logger.log(LOGGING_LEVEL, msg)
329
330     def __unicode__(self):
331         return '%s (%s)' % (self.realname, self.email)
332     
333     def conflicting_email(self):
334         q = AstakosUser.objects.exclude(username = self.username)
335         q = q.filter(email = self.email)
336         if q.count() != 0:
337             return True
338         return False
339     
340     def validate_unique_email_isactive(self):
341         """
342         Implements a unique_together constraint for email and is_active fields.
343         """
344         q = AstakosUser.objects.exclude(username = self.username)
345         q = q.filter(email = self.email)
346         q = q.filter(is_active = self.is_active)
347         if q.count() != 0:
348             raise ValidationError({'__all__':[_('Another account with the same email & is_active combination found.')]})
349     
350     @property
351     def signed_terms(self):
352         term = get_latest_terms()
353         if not term:
354             return True
355         if not self.has_signed_terms:
356             return False
357         if not self.date_signed_terms:
358             return False
359         if self.date_signed_terms < term.date:
360             self.has_signed_terms = False
361             self.date_signed_terms = None
362             self.save()
363             return False
364         return True
365
366 class Membership(models.Model):
367     person = models.ForeignKey(AstakosUser)
368     group = models.ForeignKey(AstakosGroup)
369     date_requested = models.DateField(default=datetime.now(), blank=True)
370     date_joined = models.DateField(null=True, db_index=True, blank=True)
371     
372     class Meta:
373         unique_together = ("person", "group")
374     
375     def save(self, *args, **kwargs):
376         if not self.id:
377             if not self.group.moderation_enabled:
378                 self.date_joined = datetime.now()
379         super(Membership, self).save(*args, **kwargs)
380     
381     @property
382     def is_approved(self):
383         if self.date_joined:
384             return True
385         return False
386     
387     def approve(self):
388         self.date_joined = datetime.now()
389         self.save()
390         quota_disturbed.send(sender=self, users=(self.person,))
391     
392     def disapprove(self):
393         self.delete()
394         quota_disturbed.send(sender=self, users=(self.person,))
395
396 class AstakosGroupQuota(models.Model):
397     limit = models.PositiveIntegerField('Limit')
398     resource = models.ForeignKey(Resource)
399     group = models.ForeignKey(AstakosGroup, blank=True)
400     
401     class Meta:
402         unique_together = ("resource", "group")
403
404 class AstakosUserQuota(models.Model):
405     limit = models.PositiveIntegerField('Limit')
406     resource = models.ForeignKey(Resource)
407     user = models.ForeignKey(AstakosUser)
408     
409     class Meta:
410         unique_together = ("resource", "user")
411
412 class ApprovalTerms(models.Model):
413     """
414     Model for approval terms
415     """
416
417     date = models.DateTimeField('Issue date', db_index=True, default=datetime.now())
418     location = models.CharField('Terms location', max_length=255)
419
420 class Invitation(models.Model):
421     """
422     Model for registring invitations
423     """
424     inviter = models.ForeignKey(AstakosUser, related_name='invitations_sent',
425                                 null=True)
426     realname = models.CharField('Real name', max_length=255)
427     username = models.CharField('Unique ID', max_length=255, unique=True)
428     code = models.BigIntegerField('Invitation code', db_index=True)
429     is_consumed = models.BooleanField('Consumed?', default=False)
430     created = models.DateTimeField('Creation date', auto_now_add=True)
431     consumed = models.DateTimeField('Consumption date', null=True, blank=True)
432     
433     def __init__(self, *args, **kwargs):
434         super(Invitation, self).__init__(*args, **kwargs)
435         if not self.id:
436             self.code = _generate_invitation_code()
437     
438     def consume(self):
439         self.is_consumed = True
440         self.consumed = datetime.now()
441         self.save()
442
443     def __unicode__(self):
444         return '%s -> %s [%d]' % (self.inviter, self.username, self.code)
445
446 class EmailChangeManager(models.Manager):
447     @transaction.commit_on_success
448     def change_email(self, activation_key):
449         """
450         Validate an activation key and change the corresponding
451         ``User`` if valid.
452
453         If the key is valid and has not expired, return the ``User``
454         after activating.
455
456         If the key is not valid or has expired, return ``None``.
457
458         If the key is valid but the ``User`` is already active,
459         return ``None``.
460
461         After successful email change the activation record is deleted.
462
463         Throws ValueError if there is already
464         """
465         try:
466             email_change = self.model.objects.get(activation_key=activation_key)
467             if email_change.activation_key_expired():
468                 email_change.delete()
469                 raise EmailChange.DoesNotExist
470             # is there an active user with this address?
471             try:
472                 AstakosUser.objects.get(email=email_change.new_email_address)
473             except AstakosUser.DoesNotExist:
474                 pass
475             else:
476                 raise ValueError(_('The new email address is reserved.'))
477             # update user
478             user = AstakosUser.objects.get(pk=email_change.user_id)
479             user.email = email_change.new_email_address
480             user.save()
481             email_change.delete()
482             return user
483         except EmailChange.DoesNotExist:
484             raise ValueError(_('Invalid activation key'))
485
486 class EmailChange(models.Model):
487     new_email_address = models.EmailField(_(u'new e-mail address'), help_text=_(u'Your old email address will be used until you verify your new one.'))
488     user = models.ForeignKey(AstakosUser, unique=True, related_name='emailchange_user')
489     requested_at = models.DateTimeField(default=datetime.now())
490     activation_key = models.CharField(max_length=40, unique=True, db_index=True)
491
492     objects = EmailChangeManager()
493
494     def activation_key_expired(self):
495         expiration_date = timedelta(days=EMAILCHANGE_ACTIVATION_DAYS)
496         return self.requested_at + expiration_date < datetime.now()
497
498 class AdditionalMail(models.Model):
499     """
500     Model for registring invitations
501     """
502     owner = models.ForeignKey(AstakosUser)
503     email = models.EmailField()
504
505 def _generate_invitation_code():
506     while True:
507         code = randint(1, 2L**63 - 1)
508         try:
509             Invitation.objects.get(code=code)
510             # An invitation with this code already exists, try again
511         except Invitation.DoesNotExist:
512             return code
513
514 def get_latest_terms():
515     try:
516         term = ApprovalTerms.objects.order_by('-id')[0]
517         return term
518     except IndexError:
519         pass
520     return None
521
522 def create_astakos_user(u):
523     try:
524         AstakosUser.objects.get(user_ptr=u.pk)
525     except AstakosUser.DoesNotExist:
526         extended_user = AstakosUser(user_ptr_id=u.pk)
527         extended_user.__dict__.update(u.__dict__)
528         extended_user.renew_token()
529         extended_user.save()
530     except BaseException, e:
531         logger.exception(e)
532         pass
533
534 def fix_superusers(sender, **kwargs):
535     # Associate superusers with AstakosUser
536     admins = User.objects.filter(is_superuser=True)
537     for u in admins:
538         create_astakos_user(u)
539
540 def user_post_save(sender, instance, created, **kwargs):
541     if not created:
542         return
543     create_astakos_user(instance)
544
545 def set_default_group(user):
546     try:
547         default = AstakosGroup.objects.get(name = 'default')
548         Membership(group=default, person=user, date_joined=datetime.now()).save()
549     except AstakosGroup.DoesNotExist, e:
550         logger.exception(e)
551
552 def astakosuser_pre_save(sender, instance, **kwargs):
553     instance.aquarium_report = False
554     instance.new = False
555     try:
556         db_instance = AstakosUser.objects.get(id = instance.id)
557     except AstakosUser.DoesNotExist:
558         # create event
559         instance.aquarium_report = True
560         instance.new = True
561     else:
562         get = AstakosUser.__getattribute__
563         l = filter(lambda f: get(db_instance, f) != get(instance, f),
564             BILLING_FIELDS
565         )
566         instance.aquarium_report = True if l else False
567
568 def astakosuser_post_save(sender, instance, created, **kwargs):
569     if instance.aquarium_report:
570         report_user_event(instance, create=instance.new)
571     if not created:
572         return
573     set_default_group(instance)
574     # TODO handle socket.error & IOError
575     register_users((instance,))
576
577 def send_quota_disturbed(sender, instance, **kwargs):
578     users = []
579     extend = users.extend
580     if sender == Membership:
581         if not instance.group.is_enabled:
582             return
583         extend([instance.person])
584     elif sender == AstakosUserQuota:
585         extend([instance.user])
586     elif sender == AstakosGroupQuota:
587         if not instance.group.is_enabled:
588             return
589         extend(instance.group.astakosuser_set.all())
590     elif sender == AstakosGroup:
591         if not instance.is_enabled:
592             return
593     quota_disturbed.send(sender=sender, users=users)
594
595 def on_quota_disturbed(sender, users, **kwargs):
596     print '>>>', locals()
597     if not users:
598         return
599     send_quota(users)
600
601 post_syncdb.connect(fix_superusers)
602 post_save.connect(user_post_save, sender=User)
603 pre_save.connect(astakosuser_pre_save, sender=AstakosUser)
604 post_save.connect(astakosuser_post_save, sender=AstakosUser)
605
606 quota_disturbed = Signal(providing_args=["users"])
607 quota_disturbed.connect(on_quota_disturbed)
608
609 post_delete.connect(send_quota_disturbed, sender=AstakosGroup)
610 post_delete.connect(send_quota_disturbed, sender=Membership)
611 post_save.connect(send_quota_disturbed, sender=AstakosUserQuota)
612 post_delete.connect(send_quota_disturbed, sender=AstakosUserQuota)
613 post_save.connect(send_quota_disturbed, sender=AstakosGroupQuota)
614 post_delete.connect(send_quota_disturbed, sender=AstakosGroupQuota)