Add group tables sorting & decrease interaction with database
[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,
55                                  EMAILCHANGE_ACTIVATION_DAYS, LOGGING_LEVEL)
56 from astakos.im.endpoints.quotaholder import (register_users, send_quota,
57                                               register_resources)
58 from astakos.im.endpoints.aquarium.producer import report_user_event
59
60 from astakos.im.tasks import propagate_groupmembers_quota
61
62 logger = logging.getLogger(__name__)
63
64
65 class Service(models.Model):
66     name = models.CharField('Name', max_length=255, unique=True, db_index=True)
67     url = models.FilePathField()
68     icon = models.FilePathField(blank=True)
69     auth_token = models.CharField('Authentication Token', max_length=32,
70                                   null=True, blank=True)
71     auth_token_created = models.DateTimeField('Token creation date', null=True)
72     auth_token_expires = models.DateTimeField(
73         'Token expiration date', null=True)
74
75     def save(self, **kwargs):
76         if not self.id:
77             self.renew_token()
78         self.full_clean()
79         super(Service, self).save(**kwargs)
80
81     def renew_token(self):
82         md5 = hashlib.md5()
83         md5.update(self.name.encode('ascii', 'ignore'))
84         md5.update(self.url.encode('ascii', 'ignore'))
85         md5.update(asctime())
86
87         self.auth_token = b64encode(md5.digest())
88         self.auth_token_created = datetime.now()
89         self.auth_token_expires = self.auth_token_created + \
90             timedelta(hours=AUTH_TOKEN_DURATION)
91
92     def __str__(self):
93         return self.name
94
95
96 class ResourceMetadata(models.Model):
97     key = models.CharField('Name', max_length=255, unique=True, db_index=True)
98     value = models.CharField('Value', max_length=255)
99
100
101 class Resource(models.Model):
102     name = models.CharField('Name', max_length=255, unique=True, db_index=True)
103     meta = models.ManyToManyField(ResourceMetadata)
104     service = models.ForeignKey(Service)
105
106     def __str__(self):
107         return '%s : %s' % (self.service, self.name)
108
109
110 class GroupKind(models.Model):
111     name = models.CharField('Name', max_length=255, unique=True, db_index=True)
112
113     def __str__(self):
114         return self.name
115
116
117 class AstakosGroup(Group):
118     kind = models.ForeignKey(GroupKind)
119     homepage = models.URLField(
120         'Homepage Url', max_length=255, null=True, blank=True)
121     desc = models.TextField('Description', null=True)
122     policy = models.ManyToManyField(Resource, null=True, blank=True,
123                                     through='AstakosGroupQuota'
124                                     )
125     creation_date = models.DateTimeField('Creation date',
126                                          default=datetime.now()
127                                          )
128     issue_date = models.DateTimeField('Issue date', null=True)
129     expiration_date = models.DateTimeField('Expiration date', null=True)
130     moderation_enabled = models.BooleanField('Moderated membership?',
131                                              default=True
132                                              )
133     approval_date = models.DateTimeField('Activation date', null=True,
134                                          blank=True
135                                          )
136     estimated_participants = models.PositiveIntegerField('Estimated #members',
137                                                          null=True
138                                                          )
139
140     @property
141     def is_disabled(self):
142         if not self.approval_date:
143             return True
144         return False
145
146     @property
147     def is_enabled(self):
148         if self.is_disabled:
149             return False
150         if not self.issue_date:
151             return False
152         if not self.expiration_date:
153             return True
154         now = datetime.now()
155         if self.issue_date > now:
156             return False
157         if now >= self.expiration_date:
158             return False
159         return True
160
161     def enable(self):
162         if self.is_enabled:
163             return
164         self.approval_date = datetime.now()
165         self.save()
166         quota_disturbed.send(sender=self, users=self.approved_members)
167         propagate_groupmembers_quota.apply_async(
168             args=[self], eta=self.issue_date)
169         propagate_groupmembers_quota.apply_async(
170             args=[self], eta=self.expiration_date)
171
172     def disable(self):
173         if self.is_disabled:
174             return
175         self.approval_date = None
176         self.save()
177         quota_disturbed.send(sender=self, users=self.approved_members)
178
179     def approve_member(self, person):
180         m, created = self.membership_set.get_or_create(person=person)
181         # update date_joined in any case
182         m.date_joined = datetime.now()
183         m.save()
184
185     def disapprove_member(self, person):
186         self.membership_set.remove(person=person)
187
188     @property
189     def members(self):
190         return [m.person for m in self.membership_set.all()]
191
192     @property
193     def approved_members(self):
194         return [m.person for m in self.membership_set.all() if m.is_approved]
195
196     @property
197     def quota(self):
198         d = defaultdict(int)
199         for q in self.astakosgroupquota_set.all():
200             d[q.resource] += q.uplimit
201         return d
202
203     @property
204     def owners(self):
205         return self.owner.all()
206
207     @owners.setter
208     def owners(self, l):
209         self.owner = l
210         map(self.approve_member, l)
211
212
213 class AstakosUser(User):
214     """
215     Extends ``django.contrib.auth.models.User`` by defining additional fields.
216     """
217     # Use UserManager to get the create_user method, etc.
218     objects = UserManager()
219
220     affiliation = models.CharField('Affiliation', max_length=255, blank=True)
221     provider = models.CharField('Provider', max_length=255, blank=True)
222
223     #for invitations
224     user_level = DEFAULT_USER_LEVEL
225     level = models.IntegerField('Inviter level', default=user_level)
226     invitations = models.IntegerField(
227         'Invitations left', default=INVITATIONS_PER_LEVEL.get(user_level, 0))
228
229     auth_token = models.CharField('Authentication Token', max_length=32,
230                                   null=True, blank=True)
231     auth_token_created = models.DateTimeField('Token creation date', null=True)
232     auth_token_expires = models.DateTimeField(
233         'Token expiration date', null=True)
234
235     updated = models.DateTimeField('Update date')
236     is_verified = models.BooleanField('Is verified?', default=False)
237
238     # ex. screen_name for twitter, eppn for shibboleth
239     third_party_identifier = models.CharField(
240         'Third-party identifier', max_length=255, null=True, blank=True)
241
242     email_verified = models.BooleanField('Email verified?', default=False)
243
244     has_credits = models.BooleanField('Has credits?', default=False)
245     has_signed_terms = models.BooleanField(
246         'Agree with the terms?', default=False)
247     date_signed_terms = models.DateTimeField(
248         'Signed terms date', null=True, blank=True)
249
250     activation_sent = models.DateTimeField(
251         'Activation sent data', null=True, blank=True)
252
253     policy = models.ManyToManyField(
254         Resource, null=True, through='AstakosUserQuota')
255
256     astakos_groups = models.ManyToManyField(
257         AstakosGroup, verbose_name=_('agroups'), blank=True,
258         help_text=_("In addition to the permissions manually assigned, this user will also get all permissions granted to each group he/she is in."),
259         through='Membership')
260
261     __has_signed_terms = False
262
263     owner = models.ManyToManyField(
264         AstakosGroup, related_name='owner', null=True)
265
266     class Meta:
267         unique_together = ("provider", "third_party_identifier")
268
269     def __init__(self, *args, **kwargs):
270         super(AstakosUser, self).__init__(*args, **kwargs)
271         self.__has_signed_terms = self.has_signed_terms
272         if not self.id:
273             self.is_active = False
274
275     @property
276     def realname(self):
277         return '%s %s' % (self.first_name, self.last_name)
278
279     @realname.setter
280     def realname(self, value):
281         parts = value.split(' ')
282         if len(parts) == 2:
283             self.first_name = parts[0]
284             self.last_name = parts[1]
285         else:
286             self.last_name = parts[0]
287
288     @property
289     def invitation(self):
290         try:
291             return Invitation.objects.get(username=self.email)
292         except Invitation.DoesNotExist:
293             return None
294
295     @property
296     def quota(self):
297         d = defaultdict(int)
298         for q in self.astakosuserquota_set.all():
299             d[q.resource.name] += q.uplimit
300         for m in self.membership_set.all():
301             if not m.is_approved:
302                 continue
303             g = m.group
304             if not g.is_enabled:
305                 continue
306             for r, uplimit in g.quota.iteritems():
307                 d[r] += uplimit
308         # TODO set default for remaining
309         return d
310
311     def save(self, update_timestamps=True, **kwargs):
312         if update_timestamps:
313             if not self.id:
314                 self.date_joined = datetime.now()
315             self.updated = datetime.now()
316
317         # update date_signed_terms if necessary
318         if self.__has_signed_terms != self.has_signed_terms:
319             self.date_signed_terms = datetime.now()
320
321         if not self.id:
322             # set username
323             while not self.username:
324                 username = uuid.uuid4().hex[:30]
325                 try:
326                     AstakosUser.objects.get(username=username)
327                 except AstakosUser.DoesNotExist:
328                     self.username = username
329             if not self.provider:
330                 self.provider = 'local'
331         self.validate_unique_email_isactive()
332         if self.is_active and self.activation_sent:
333             # reset the activation sent
334             self.activation_sent = None
335
336         super(AstakosUser, self).save(**kwargs)
337
338     def renew_token(self):
339         md5 = hashlib.md5()
340         md5.update(self.username)
341         md5.update(self.realname.encode('ascii', 'ignore'))
342         md5.update(asctime())
343
344         self.auth_token = b64encode(md5.digest())
345         self.auth_token_created = datetime.now()
346         self.auth_token_expires = self.auth_token_created + \
347             timedelta(hours=AUTH_TOKEN_DURATION)
348         msg = 'Token renewed for %s' % self.email
349         logger.log(LOGGING_LEVEL, msg)
350
351     def __unicode__(self):
352         return '%s (%s)' % (self.realname, self.email)
353
354     def conflicting_email(self):
355         q = AstakosUser.objects.exclude(username=self.username)
356         q = q.filter(email=self.email)
357         if q.count() != 0:
358             return True
359         return False
360
361     def validate_unique_email_isactive(self):
362         """
363         Implements a unique_together constraint for email and is_active fields.
364         """
365         q = AstakosUser.objects.exclude(username=self.username)
366         q = q.filter(email=self.email)
367         q = q.filter(is_active=self.is_active)
368         if q.count() != 0:
369             raise ValidationError({'__all__': [_('Another account with the same email & is_active combination found.')]})
370
371     @property
372     def signed_terms(self):
373         term = get_latest_terms()
374         if not term:
375             return True
376         if not self.has_signed_terms:
377             return False
378         if not self.date_signed_terms:
379             return False
380         if self.date_signed_terms < term.date:
381             self.has_signed_terms = False
382             self.date_signed_terms = None
383             self.save()
384             return False
385         return True
386
387
388 class Membership(models.Model):
389     person = models.ForeignKey(AstakosUser)
390     group = models.ForeignKey(AstakosGroup)
391     date_requested = models.DateField(default=datetime.now(), blank=True)
392     date_joined = models.DateField(null=True, db_index=True, blank=True)
393
394     class Meta:
395         unique_together = ("person", "group")
396
397     def save(self, *args, **kwargs):
398         if not self.id:
399             if not self.group.moderation_enabled:
400                 self.date_joined = datetime.now()
401         super(Membership, self).save(*args, **kwargs)
402
403     @property
404     def is_approved(self):
405         if self.date_joined:
406             return True
407         return False
408
409     def approve(self):
410         self.date_joined = datetime.now()
411         self.save()
412         quota_disturbed.send(sender=self, users=(self.person,))
413
414     def disapprove(self):
415         self.delete()
416         quota_disturbed.send(sender=self, users=(self.person,))
417
418
419 class AstakosGroupQuota(models.Model):
420     limit = models.PositiveIntegerField('Limit')    # obsolete field
421     uplimit = models.BigIntegerField('Up limit', null=True)
422     resource = models.ForeignKey(Resource)
423     group = models.ForeignKey(AstakosGroup, blank=True)
424
425     class Meta:
426         unique_together = ("resource", "group")
427
428
429 class AstakosUserQuota(models.Model):
430     limit = models.PositiveIntegerField('Limit')    # obsolete field
431     uplimit = models.BigIntegerField('Up limit', null=True)
432     resource = models.ForeignKey(Resource)
433     user = models.ForeignKey(AstakosUser)
434
435     class Meta:
436         unique_together = ("resource", "user")
437
438
439 class ApprovalTerms(models.Model):
440     """
441     Model for approval terms
442     """
443
444     date = models.DateTimeField(
445         'Issue date', db_index=True, default=datetime.now())
446     location = models.CharField('Terms location', max_length=255)
447
448
449 class Invitation(models.Model):
450     """
451     Model for registring invitations
452     """
453     inviter = models.ForeignKey(AstakosUser, related_name='invitations_sent',
454                                 null=True)
455     realname = models.CharField('Real name', max_length=255)
456     username = models.CharField('Unique ID', max_length=255, unique=True)
457     code = models.BigIntegerField('Invitation code', db_index=True)
458     is_consumed = models.BooleanField('Consumed?', default=False)
459     created = models.DateTimeField('Creation date', auto_now_add=True)
460     consumed = models.DateTimeField('Consumption date', null=True, blank=True)
461
462     def __init__(self, *args, **kwargs):
463         super(Invitation, self).__init__(*args, **kwargs)
464         if not self.id:
465             self.code = _generate_invitation_code()
466
467     def consume(self):
468         self.is_consumed = True
469         self.consumed = datetime.now()
470         self.save()
471
472     def __unicode__(self):
473         return '%s -> %s [%d]' % (self.inviter, self.username, self.code)
474
475
476 class EmailChangeManager(models.Manager):
477     @transaction.commit_on_success
478     def change_email(self, activation_key):
479         """
480         Validate an activation key and change the corresponding
481         ``User`` if valid.
482
483         If the key is valid and has not expired, return the ``User``
484         after activating.
485
486         If the key is not valid or has expired, return ``None``.
487
488         If the key is valid but the ``User`` is already active,
489         return ``None``.
490
491         After successful email change the activation record is deleted.
492
493         Throws ValueError if there is already
494         """
495         try:
496             email_change = self.model.objects.get(
497                 activation_key=activation_key)
498             if email_change.activation_key_expired():
499                 email_change.delete()
500                 raise EmailChange.DoesNotExist
501             # is there an active user with this address?
502             try:
503                 AstakosUser.objects.get(email=email_change.new_email_address)
504             except AstakosUser.DoesNotExist:
505                 pass
506             else:
507                 raise ValueError(_('The new email address is reserved.'))
508             # update user
509             user = AstakosUser.objects.get(pk=email_change.user_id)
510             user.email = email_change.new_email_address
511             user.save()
512             email_change.delete()
513             return user
514         except EmailChange.DoesNotExist:
515             raise ValueError(_('Invalid activation key'))
516
517
518 class EmailChange(models.Model):
519     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.'))
520     user = models.ForeignKey(
521         AstakosUser, unique=True, related_name='emailchange_user')
522     requested_at = models.DateTimeField(default=datetime.now())
523     activation_key = models.CharField(
524         max_length=40, unique=True, db_index=True)
525
526     objects = EmailChangeManager()
527
528     def activation_key_expired(self):
529         expiration_date = timedelta(days=EMAILCHANGE_ACTIVATION_DAYS)
530         return self.requested_at + expiration_date < datetime.now()
531
532
533 class AdditionalMail(models.Model):
534     """
535     Model for registring invitations
536     """
537     owner = models.ForeignKey(AstakosUser)
538     email = models.EmailField()
539
540
541 def _generate_invitation_code():
542     while True:
543         code = randint(1, 2L ** 63 - 1)
544         try:
545             Invitation.objects.get(code=code)
546             # An invitation with this code already exists, try again
547         except Invitation.DoesNotExist:
548             return code
549
550
551 def get_latest_terms():
552     try:
553         term = ApprovalTerms.objects.order_by('-id')[0]
554         return term
555     except IndexError:
556         pass
557     return None
558
559
560 def create_astakos_user(u):
561     try:
562         AstakosUser.objects.get(user_ptr=u.pk)
563     except AstakosUser.DoesNotExist:
564         extended_user = AstakosUser(user_ptr_id=u.pk)
565         extended_user.__dict__.update(u.__dict__)
566         extended_user.renew_token()
567         extended_user.save()
568     except BaseException, e:
569         logger.exception(e)
570         pass
571
572
573 def fix_superusers(sender, **kwargs):
574     # Associate superusers with AstakosUser
575     admins = User.objects.filter(is_superuser=True)
576     for u in admins:
577         create_astakos_user(u)
578
579
580 def user_post_save(sender, instance, created, **kwargs):
581     if not created:
582         return
583     create_astakos_user(instance)
584
585
586 def set_default_group(user):
587     try:
588         default = AstakosGroup.objects.get(name='default')
589         Membership(
590             group=default, person=user, date_joined=datetime.now()).save()
591     except AstakosGroup.DoesNotExist, e:
592         logger.exception(e)
593
594
595 def astakosuser_pre_save(sender, instance, **kwargs):
596     instance.aquarium_report = False
597     instance.new = False
598     try:
599         db_instance = AstakosUser.objects.get(id=instance.id)
600     except AstakosUser.DoesNotExist:
601         # create event
602         instance.aquarium_report = True
603         instance.new = True
604     else:
605         get = AstakosUser.__getattribute__
606         l = filter(lambda f: get(db_instance, f) != get(instance, f),
607                    BILLING_FIELDS
608                    )
609         instance.aquarium_report = True if l else False
610
611
612 def astakosuser_post_save(sender, instance, created, **kwargs):
613     if instance.aquarium_report:
614         report_user_event(instance, create=instance.new)
615     if not created:
616         return
617     set_default_group(instance)
618     # TODO handle socket.error & IOError
619     register_users((instance,))
620
621
622 def resource_post_save(sender, instance, created, **kwargs):
623     if not created:
624         return
625     register_resources((instance,))
626
627
628 def send_quota_disturbed(sender, instance, **kwargs):
629     users = []
630     extend = users.extend
631     if sender == Membership:
632         if not instance.group.is_enabled:
633             return
634         extend([instance.person])
635     elif sender == AstakosUserQuota:
636         extend([instance.user])
637     elif sender == AstakosGroupQuota:
638         if not instance.group.is_enabled:
639             return
640         extend(instance.group.astakosuser_set.all())
641     elif sender == AstakosGroup:
642         if not instance.is_enabled:
643             return
644     quota_disturbed.send(sender=sender, users=users)
645
646
647 def on_quota_disturbed(sender, users, **kwargs):
648     print '>>>', locals()
649     if not users:
650         return
651     send_quota(users)
652
653 post_syncdb.connect(fix_superusers)
654 post_save.connect(user_post_save, sender=User)
655 pre_save.connect(astakosuser_pre_save, sender=AstakosUser)
656 post_save.connect(astakosuser_post_save, sender=AstakosUser)
657 post_save.connect(resource_post_save, sender=Resource)
658
659 quota_disturbed = Signal(providing_args=["users"])
660 quota_disturbed.connect(on_quota_disturbed)
661
662 post_delete.connect(send_quota_disturbed, sender=AstakosGroup)
663 post_delete.connect(send_quota_disturbed, sender=Membership)
664 post_save.connect(send_quota_disturbed, sender=AstakosUserQuota)
665 post_delete.connect(send_quota_disturbed, sender=AstakosUserQuota)
666 post_save.connect(send_quota_disturbed, sender=AstakosGroupQuota)
667 post_delete.connect(send_quota_disturbed, sender=AstakosGroupQuota)