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