1 # Copyright 2011-2012 GRNET S.A. All rights reserved.
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
7 # 1. Redistributions of source code must retain the above
8 # copyright notice, this list of conditions and the following
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.
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.
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.
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
44 from django.db import models, IntegrityError
45 from django.contrib.auth.models import User, UserManager, Group, Permission
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,
51 from django.contrib.contenttypes.models import ContentType
53 from django.dispatch import Signal
54 from django.db.models import Q
56 from astakos.im.settings import (DEFAULT_USER_LEVEL, INVITATIONS_PER_LEVEL,
57 AUTH_TOKEN_DURATION, BILLING_FIELDS,
58 EMAILCHANGE_ACTIVATION_DAYS, LOGGING_LEVEL)
59 from astakos.im.endpoints.quotaholder import (register_users, send_quota,
61 from astakos.im.endpoints.aquarium.producer import report_user_event
62 from astakos.im.functions import send_invitation
63 from astakos.im.tasks import propagate_groupmembers_quota
64 from astakos.im.functions import send_invitation
66 logger = logging.getLogger(__name__)
68 DEFAULT_CONTENT_TYPE = None
70 content_type = ContentType.objects.get(app_label='im', model='astakosuser')
72 content_type = DEFAULT_CONTENT_TYPE
74 RESOURCE_SEPARATOR = '.'
77 class Service(models.Model):
78 name = models.CharField('Name', max_length=255, unique=True, db_index=True)
79 url = models.FilePathField()
80 icon = models.FilePathField(blank=True)
81 auth_token = models.CharField('Authentication Token', max_length=32,
82 null=True, blank=True)
83 auth_token_created = models.DateTimeField('Token creation date', null=True)
84 auth_token_expires = models.DateTimeField(
85 'Token expiration date', null=True)
87 def save(self, **kwargs):
90 super(Service, self).save(**kwargs)
92 def renew_token(self):
94 md5.update(self.name.encode('ascii', 'ignore'))
95 md5.update(self.url.encode('ascii', 'ignore'))
98 self.auth_token = b64encode(md5.digest())
99 self.auth_token_created = datetime.now()
100 self.auth_token_expires = self.auth_token_created + \
101 timedelta(hours=AUTH_TOKEN_DURATION)
108 return self.resource_set.all()
111 def resources(self, resources):
113 self.resource_set.create(**s)
115 def add_resource(self, service, resource, uplimit, update=True):
116 """Raises ObjectDoesNotExist, IntegrityError"""
117 resource = Resource.objects.get(service__name=service, name=resource)
119 AstakosUserQuota.objects.update_or_create(user=self,
121 defaults={'uplimit': uplimit})
123 q = self.astakosuserquota_set
124 q.create(resource=resource, uplimit=uplimit)
127 class ResourceMetadata(models.Model):
128 key = models.CharField('Name', max_length=255, unique=True, db_index=True)
129 value = models.CharField('Value', max_length=255)
132 class Resource(models.Model):
133 name = models.CharField('Name', max_length=255, unique=True, db_index=True)
134 meta = models.ManyToManyField(ResourceMetadata)
135 service = models.ForeignKey(Service)
136 desc = models.TextField('Description', null=True)
137 unit = models.CharField('Name', null=True, max_length=255)
138 group = models.CharField('Group', null=True, max_length=255)
141 return '%s%s%s' % (self.service, RESOURCE_SEPARATOR, self.name)
144 class GroupKind(models.Model):
145 name = models.CharField('Name', max_length=255, unique=True, db_index=True)
151 class AstakosGroup(Group):
152 kind = models.ForeignKey(GroupKind)
153 homepage = models.URLField(
154 'Homepage Url', max_length=255, null=True, blank=True)
155 desc = models.TextField('Description', null=True)
156 policy = models.ManyToManyField(Resource, null=True, blank=True,
157 through='AstakosGroupQuota')
158 creation_date = models.DateTimeField('Creation date',
159 default=datetime.now())
160 issue_date = models.DateTimeField('Issue date', null=True)
161 expiration_date = models.DateTimeField('Expiration date', null=True)
162 moderation_enabled = models.BooleanField('Moderated membership?',
164 approval_date = models.DateTimeField('Activation date', null=True,
166 estimated_participants = models.PositiveIntegerField('Estimated #members',
170 def is_disabled(self):
171 if not self.approval_date:
176 def is_enabled(self):
179 if not self.issue_date:
181 if not self.expiration_date:
184 if self.issue_date > now:
186 if now >= self.expiration_date:
193 self.approval_date = datetime.now()
195 quota_disturbed.send(sender=self, users=self.approved_members)
196 propagate_groupmembers_quota.apply_async(
197 args=[self], eta=self.issue_date)
198 propagate_groupmembers_quota.apply_async(
199 args=[self], eta=self.expiration_date)
204 self.approval_date = None
206 quota_disturbed.send(sender=self, users=self.approved_members)
208 def approve_member(self, person):
209 m, created = self.membership_set.get_or_create(person=person)
210 # update date_joined in any case
211 m.date_joined = datetime.now()
214 def disapprove_member(self, person):
215 self.membership_set.remove(person=person)
219 q = self.membership_set.select_related().all()
220 return [m.person for m in q]
223 def approved_members(self):
224 q = self.membership_set.select_related().all()
225 return [m.person for m in q if m.is_approved]
230 for q in self.astakosgroupquota_set.select_related().all():
231 d[q.resource] += q.uplimit
234 def add_policy(self, service, resource, uplimit, update=True):
235 """Raises ObjectDoesNotExist, IntegrityError"""
237 resource = Resource.objects.get(service__name=service, name=resource)
239 AstakosGroupQuota.objects.update_or_create(
242 defaults={'uplimit': uplimit}
245 q = self.astakosgroupquota_set
246 q.create(resource=resource, uplimit=uplimit)
250 return self.astakosgroupquota_set.select_related().all()
253 def policies(self, policies):
255 service = policies.get('service', None)
256 resource = policies.get('resource', None)
257 uplimit = policies.get('uplimit', 0)
258 update = policies.get('update', True)
259 self.add_policy(service, resource, uplimit, update)
263 return self.owner.all()
266 def owner_details(self):
267 return self.owner.select_related().all()
272 map(self.approve_member, l)
275 class AstakosUser(User):
277 Extends ``django.contrib.auth.models.User`` by defining additional fields.
279 # Use UserManager to get the create_user method, etc.
280 objects = UserManager()
282 affiliation = models.CharField('Affiliation', max_length=255, blank=True)
283 provider = models.CharField('Provider', max_length=255, blank=True)
286 user_level = DEFAULT_USER_LEVEL
287 level = models.IntegerField('Inviter level', default=user_level)
288 invitations = models.IntegerField(
289 'Invitations left', default=INVITATIONS_PER_LEVEL.get(user_level, 0))
291 auth_token = models.CharField('Authentication Token', max_length=32,
292 null=True, blank=True)
293 auth_token_created = models.DateTimeField('Token creation date', null=True)
294 auth_token_expires = models.DateTimeField(
295 'Token expiration date', null=True)
297 updated = models.DateTimeField('Update date')
298 is_verified = models.BooleanField('Is verified?', default=False)
300 # ex. screen_name for twitter, eppn for shibboleth
301 third_party_identifier = models.CharField(
302 'Third-party identifier', max_length=255, null=True, blank=True)
304 email_verified = models.BooleanField('Email verified?', default=False)
306 has_credits = models.BooleanField('Has credits?', default=False)
307 has_signed_terms = models.BooleanField(
308 'I agree with the terms', default=False)
309 date_signed_terms = models.DateTimeField(
310 'Signed terms date', null=True, blank=True)
312 activation_sent = models.DateTimeField(
313 'Activation sent data', null=True, blank=True)
315 policy = models.ManyToManyField(
316 Resource, null=True, through='AstakosUserQuota')
318 astakos_groups = models.ManyToManyField(
319 AstakosGroup, verbose_name=_('agroups'), blank=True,
320 help_text=_("""In addition to the permissions manually assigned, this
321 user will also get all permissions granted to each group
323 through='Membership')
325 __has_signed_terms = False
326 dirsturbed_quota = models.BooleanField('Needs quotaholder syncing',
327 default=False, db_index=True)
329 owner = models.ManyToManyField(
330 AstakosGroup, related_name='owner', null=True)
333 unique_together = ("provider", "third_party_identifier")
335 def __init__(self, *args, **kwargs):
336 super(AstakosUser, self).__init__(*args, **kwargs)
337 self.__has_signed_terms = self.has_signed_terms
338 if not self.id and not self.is_active:
339 self.is_active = False
343 return '%s %s' % (self.first_name, self.last_name)
346 def realname(self, value):
347 parts = value.split(' ')
349 self.first_name = parts[0]
350 self.last_name = parts[1]
352 self.last_name = parts[0]
354 def add_permission(self, pname):
355 if self.has_perm(pname):
357 p, created = Permission.objects.get_or_create(codename=pname,
358 name=pname.capitalize(),
359 content_type=content_type)
360 self.user_permissions.add(p)
362 def remove_permission(self, pname):
363 if self.has_perm(pname):
365 p = Permission.objects.get(codename=pname,
366 content_type=content_type)
367 self.user_permissions.remove(p)
370 def invitation(self):
372 return Invitation.objects.get(username=self.email)
373 except Invitation.DoesNotExist:
376 def invite(self, email, realname):
377 inv = Invitation(inviter=self, username=email, realname=realname)
380 self.invitations = max(0, self.invitations - 1)
385 """Returns a dict with the sum of quota limits per resource"""
387 for q in self.policies:
388 d[q.resource] += q.uplimit
389 for m in self.extended_groups:
390 if not m.is_approved:
395 for r, uplimit in g.quota.iteritems():
398 # TODO set default for remaining
403 return self.astakosuserquota_set.select_related().all()
406 def policies(self, policies):
408 service = policies.get('service', None)
409 resource = policies.get('resource', None)
410 uplimit = policies.get('uplimit', 0)
411 update = policies.get('update', True)
412 self.add_policy(service, resource, uplimit, update)
414 def add_policy(self, service, resource, uplimit, update=True):
415 """Raises ObjectDoesNotExist, IntegrityError"""
416 resource = Resource.objects.get(service__name=service, name=resource)
418 AstakosUserQuota.objects.update_or_create(user=self,
420 defaults={'uplimit': uplimit})
422 q = self.astakosuserquota_set
423 q.create(resource=resource, uplimit=uplimit)
425 def remove_policy(self, service, resource):
426 """Raises ObjectDoesNotExist, IntegrityError"""
427 resource = Resource.objects.get(service__name=service, name=resource)
428 q = self.policies.get(resource=resource).delete()
431 def extended_groups(self):
432 return self.membership_set.select_related().all()
434 @extended_groups.setter
435 def extended_groups(self, groups):
438 group = AstakosGroup.objects.get(name=name)
439 self.membership_set.create(group=group)
441 def save(self, update_timestamps=True, **kwargs):
442 if update_timestamps:
444 self.date_joined = datetime.now()
445 self.updated = datetime.now()
447 # update date_signed_terms if necessary
448 if self.__has_signed_terms != self.has_signed_terms:
449 self.date_signed_terms = datetime.now()
453 while not self.username:
454 username = uuid.uuid4().hex[:30]
456 AstakosUser.objects.get(username=username)
457 except AstakosUser.DoesNotExist:
458 self.username = username
459 if not self.provider:
460 self.provider = 'local'
461 self.email = self.email.lower()
462 self.validate_unique_email_isactive()
463 if self.is_active and self.activation_sent:
464 # reset the activation sent
465 self.activation_sent = None
467 super(AstakosUser, self).save(**kwargs)
469 def renew_token(self):
471 md5.update(self.username)
472 md5.update(self.realname.encode('ascii', 'ignore'))
473 md5.update(asctime())
475 self.auth_token = b64encode(md5.digest())
476 self.auth_token_created = datetime.now()
477 self.auth_token_expires = self.auth_token_created + \
478 timedelta(hours=AUTH_TOKEN_DURATION)
479 msg = 'Token renewed for %s' % self.email
480 logger.log(LOGGING_LEVEL, msg)
482 def __unicode__(self):
483 return '%s (%s)' % (self.realname, self.email)
485 def conflicting_email(self):
486 q = AstakosUser.objects.exclude(username=self.username)
487 q = q.filter(email=self.email)
492 def validate_unique_email_isactive(self):
494 Implements a unique_together constraint for email and is_active fields.
496 q = AstakosUser.objects.exclude(username=self.username)
497 q = q.filter(email=self.email)
498 q = q.filter(is_active=self.is_active)
500 raise ValidationError({'__all__': [_('Another account with the same email & is_active combination found.')]})
503 def signed_terms(self):
504 term = get_latest_terms()
507 if not self.has_signed_terms:
509 if not self.date_signed_terms:
511 if self.date_signed_terms < term.date:
512 self.has_signed_terms = False
513 self.date_signed_terms = None
518 def store_disturbed_quota(self, set=True):
519 self.disturbed_quota = set
523 class Membership(models.Model):
524 person = models.ForeignKey(AstakosUser)
525 group = models.ForeignKey(AstakosGroup)
526 date_requested = models.DateField(default=datetime.now(), blank=True)
527 date_joined = models.DateField(null=True, db_index=True, blank=True)
530 unique_together = ("person", "group")
532 def save(self, *args, **kwargs):
534 if not self.group.moderation_enabled:
535 self.date_joined = datetime.now()
536 super(Membership, self).save(*args, **kwargs)
539 def is_approved(self):
545 self.date_joined = datetime.now()
547 quota_disturbed.send(sender=self, users=(self.person,))
549 def disapprove(self):
551 quota_disturbed.send(sender=self, users=(self.person,))
553 class AstakosQuotaManager(models.Manager):
554 def _update_or_create(self, **kwargs):
556 'update_or_create() must be passed at least one keyword argument'
557 obj, created = self.get_or_create(**kwargs)
558 defaults = kwargs.pop('defaults', {})
560 return obj, True, False
564 [(k, v) for k, v in kwargs.items() if '__' not in k])
565 params.update(defaults)
566 for attr, val in params.items():
567 if hasattr(obj, attr):
568 setattr(obj, attr, val)
569 sid = transaction.savepoint()
570 obj.save(force_update=True)
571 transaction.savepoint_commit(sid)
572 return obj, False, True
573 except IntegrityError, e:
574 transaction.savepoint_rollback(sid)
576 return self.get(**kwargs), False, False
577 except self.model.DoesNotExist:
580 update_or_create = _update_or_create
582 class AstakosGroupQuota(models.Model):
583 objects = AstakosQuotaManager()
584 limit = models.PositiveIntegerField('Limit', null=True) # obsolete field
585 uplimit = models.BigIntegerField('Up limit', null=True)
586 resource = models.ForeignKey(Resource)
587 group = models.ForeignKey(AstakosGroup, blank=True)
590 unique_together = ("resource", "group")
592 class AstakosUserQuota(models.Model):
593 objects = AstakosQuotaManager()
594 limit = models.PositiveIntegerField('Limit', null=True) # obsolete field
595 uplimit = models.BigIntegerField('Up limit', null=True)
596 resource = models.ForeignKey(Resource)
597 user = models.ForeignKey(AstakosUser)
600 unique_together = ("resource", "user")
603 class ApprovalTerms(models.Model):
605 Model for approval terms
608 date = models.DateTimeField(
609 'Issue date', db_index=True, default=datetime.now())
610 location = models.CharField('Terms location', max_length=255)
613 class Invitation(models.Model):
615 Model for registring invitations
617 inviter = models.ForeignKey(AstakosUser, related_name='invitations_sent',
619 realname = models.CharField('Real name', max_length=255)
620 username = models.CharField('Unique ID', max_length=255, unique=True)
621 code = models.BigIntegerField('Invitation code', db_index=True)
622 is_consumed = models.BooleanField('Consumed?', default=False)
623 created = models.DateTimeField('Creation date', auto_now_add=True)
624 consumed = models.DateTimeField('Consumption date', null=True, blank=True)
626 def __init__(self, *args, **kwargs):
627 super(Invitation, self).__init__(*args, **kwargs)
629 self.code = _generate_invitation_code()
632 self.is_consumed = True
633 self.consumed = datetime.now()
636 def __unicode__(self):
637 return '%s -> %s [%d]' % (self.inviter, self.username, self.code)
640 class EmailChangeManager(models.Manager):
641 @transaction.commit_on_success
642 def change_email(self, activation_key):
644 Validate an activation key and change the corresponding
647 If the key is valid and has not expired, return the ``User``
650 If the key is not valid or has expired, return ``None``.
652 If the key is valid but the ``User`` is already active,
655 After successful email change the activation record is deleted.
657 Throws ValueError if there is already
660 email_change = self.model.objects.get(
661 activation_key=activation_key)
662 if email_change.activation_key_expired():
663 email_change.delete()
664 raise EmailChange.DoesNotExist
665 # is there an active user with this address?
667 AstakosUser.objects.get(email=email_change.new_email_address)
668 except AstakosUser.DoesNotExist:
671 raise ValueError(_('The new email address is reserved.'))
673 user = AstakosUser.objects.get(pk=email_change.user_id)
674 user.email = email_change.new_email_address
676 email_change.delete()
678 except EmailChange.DoesNotExist:
679 raise ValueError(_('Invalid activation key'))
682 class EmailChange(models.Model):
683 new_email_address = models.EmailField(_(u'new e-mail address'),
684 help_text=_(u'Your old email address will be used until you verify your new one.'))
685 user = models.ForeignKey(
686 AstakosUser, unique=True, related_name='emailchange_user')
687 requested_at = models.DateTimeField(default=datetime.now())
688 activation_key = models.CharField(
689 max_length=40, unique=True, db_index=True)
691 objects = EmailChangeManager()
693 def activation_key_expired(self):
694 expiration_date = timedelta(days=EMAILCHANGE_ACTIVATION_DAYS)
695 return self.requested_at + expiration_date < datetime.now()
698 class AdditionalMail(models.Model):
700 Model for registring invitations
702 owner = models.ForeignKey(AstakosUser)
703 email = models.EmailField()
706 def _generate_invitation_code():
708 code = randint(1, 2L ** 63 - 1)
710 Invitation.objects.get(code=code)
711 # An invitation with this code already exists, try again
712 except Invitation.DoesNotExist:
716 def get_latest_terms():
718 term = ApprovalTerms.objects.order_by('-id')[0]
725 def create_astakos_user(u):
727 AstakosUser.objects.get(user_ptr=u.pk)
728 except AstakosUser.DoesNotExist:
729 extended_user = AstakosUser(user_ptr_id=u.pk)
730 extended_user.__dict__.update(u.__dict__)
731 extended_user.renew_token()
733 except BaseException, e:
738 def fix_superusers(sender, **kwargs):
739 # Associate superusers with AstakosUser
740 admins = User.objects.filter(is_superuser=True)
742 create_astakos_user(u)
745 def user_post_save(sender, instance, created, **kwargs):
748 create_astakos_user(instance)
751 def set_default_group(user):
753 default = AstakosGroup.objects.get(name='default')
755 group=default, person=user, date_joined=datetime.now()).save()
756 except AstakosGroup.DoesNotExist, e:
760 def astakosuser_pre_save(sender, instance, **kwargs):
761 instance.aquarium_report = False
764 db_instance = AstakosUser.objects.get(id=instance.id)
765 except AstakosUser.DoesNotExist:
767 instance.aquarium_report = True
770 get = AstakosUser.__getattribute__
771 l = filter(lambda f: get(db_instance, f) != get(instance, f),
773 instance.aquarium_report = True if l else False
776 def astakosuser_post_save(sender, instance, created, **kwargs):
777 if instance.aquarium_report:
778 report_user_event(instance, create=instance.new)
781 set_default_group(instance)
782 # TODO handle socket.error & IOError
783 register_users((instance,))
784 instance.renew_token()
787 def resource_post_save(sender, instance, created, **kwargs):
790 register_resources((instance,))
793 def send_quota_disturbed(sender, instance, **kwargs):
795 extend = users.extend
796 if sender == Membership:
797 if not instance.group.is_enabled:
799 extend([instance.person])
800 elif sender == AstakosUserQuota:
801 extend([instance.user])
802 elif sender == AstakosGroupQuota:
803 if not instance.group.is_enabled:
805 extend(instance.group.astakosuser_set.all())
806 elif sender == AstakosGroup:
807 if not instance.is_enabled:
809 map(lambda u: u.store_disturbed_quota, users)
811 # def on_quota_disturbed(sender, users, **kwargs):
812 # print '>>>', locals()
817 post_syncdb.connect(fix_superusers)
818 post_save.connect(user_post_save, sender=User)
819 pre_save.connect(astakosuser_pre_save, sender=AstakosUser)
820 post_save.connect(astakosuser_post_save, sender=AstakosUser)
821 post_save.connect(resource_post_save, sender=Resource)
823 quota_disturbed = Signal(providing_args=["users"])
824 # quota_disturbed.connect(on_quota_disturbed)
826 post_delete.connect(send_quota_disturbed, sender=AstakosGroup)
827 post_delete.connect(send_quota_disturbed, sender=Membership)
828 post_save.connect(send_quota_disturbed, sender=AstakosUserQuota)
829 post_delete.connect(send_quota_disturbed, sender=AstakosUserQuota)
830 post_save.connect(send_quota_disturbed, sender=AstakosGroupQuota)
831 post_delete.connect(send_quota_disturbed, sender=AstakosGroupQuota)