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(
160 through='AstakosGroupQuota'
162 creation_date = models.DateTimeField(
164 default=datetime.now()
166 issue_date = models.DateTimeField('Issue date', null=True)
167 expiration_date = models.DateTimeField(
171 moderation_enabled = models.BooleanField(
172 'Moderated membership?',
175 approval_date = models.DateTimeField(
180 estimated_participants = models.PositiveIntegerField(
181 'Estimated #members',
185 max_participants = models.PositiveIntegerField(
186 'Maximum numder of participants',
192 def is_disabled(self):
193 if not self.approval_date:
198 def is_enabled(self):
201 if not self.issue_date:
203 if not self.expiration_date:
206 if self.issue_date > now:
208 if now >= self.expiration_date:
215 self.approval_date = datetime.now()
217 quota_disturbed.send(sender=self, users=self.approved_members)
218 propagate_groupmembers_quota.apply_async(
219 args=[self], eta=self.issue_date)
220 propagate_groupmembers_quota.apply_async(
221 args=[self], eta=self.expiration_date)
226 self.approval_date = None
228 quota_disturbed.send(sender=self, users=self.approved_members)
230 def approve_member(self, person):
231 m, created = self.membership_set.get_or_create(person=person)
232 # update date_joined in any case
233 m.date_joined = datetime.now()
236 def disapprove_member(self, person):
237 self.membership_set.remove(person=person)
241 q = self.membership_set.select_related().all()
242 return [m.person for m in q]
245 def approved_members(self):
246 q = self.membership_set.select_related().all()
247 return [m.person for m in q if m.is_approved]
252 for q in self.astakosgroupquota_set.select_related().all():
253 d[q.resource] += q.uplimit
256 def add_policy(self, service, resource, uplimit, update=True):
257 """Raises ObjectDoesNotExist, IntegrityError"""
259 resource = Resource.objects.get(service__name=service, name=resource)
261 AstakosGroupQuota.objects.update_or_create(
264 defaults={'uplimit': uplimit}
267 q = self.astakosgroupquota_set
268 q.create(resource=resource, uplimit=uplimit)
272 return self.astakosgroupquota_set.select_related().all()
275 def policies(self, policies):
277 service = p.get('service', None)
278 resource = p.get('resource', None)
279 uplimit = p.get('uplimit', 0)
280 update = p.get('update', True)
281 self.add_policy(service, resource, uplimit, update)
285 return self.owner.all()
288 def owner_details(self):
289 return self.owner.select_related().all()
294 map(self.approve_member, l)
297 class AstakosUser(User):
299 Extends ``django.contrib.auth.models.User`` by defining additional fields.
301 # Use UserManager to get the create_user method, etc.
302 objects = UserManager()
304 affiliation = models.CharField('Affiliation', max_length=255, blank=True)
305 provider = models.CharField('Provider', max_length=255, blank=True)
308 user_level = DEFAULT_USER_LEVEL
309 level = models.IntegerField('Inviter level', default=user_level)
310 invitations = models.IntegerField(
311 'Invitations left', default=INVITATIONS_PER_LEVEL.get(user_level, 0))
313 auth_token = models.CharField('Authentication Token', max_length=32,
314 null=True, blank=True)
315 auth_token_created = models.DateTimeField('Token creation date', null=True)
316 auth_token_expires = models.DateTimeField(
317 'Token expiration date', null=True)
319 updated = models.DateTimeField('Update date')
320 is_verified = models.BooleanField('Is verified?', default=False)
322 # ex. screen_name for twitter, eppn for shibboleth
323 third_party_identifier = models.CharField(
324 'Third-party identifier', max_length=255, null=True, blank=True)
326 email_verified = models.BooleanField('Email verified?', default=False)
328 has_credits = models.BooleanField('Has credits?', default=False)
329 has_signed_terms = models.BooleanField(
330 'I agree with the terms', default=False)
331 date_signed_terms = models.DateTimeField(
332 'Signed terms date', null=True, blank=True)
334 activation_sent = models.DateTimeField(
335 'Activation sent data', null=True, blank=True)
337 policy = models.ManyToManyField(
338 Resource, null=True, through='AstakosUserQuota')
340 astakos_groups = models.ManyToManyField(
341 AstakosGroup, verbose_name=_('agroups'), blank=True,
342 help_text=_("""In addition to the permissions manually assigned, this
343 user will also get all permissions granted to each group
345 through='Membership')
347 __has_signed_terms = False
348 disturbed_quota = models.BooleanField('Needs quotaholder syncing',
349 default=False, db_index=True)
351 owner = models.ManyToManyField(
352 AstakosGroup, related_name='owner', null=True)
355 unique_together = ("provider", "third_party_identifier")
357 def __init__(self, *args, **kwargs):
358 super(AstakosUser, self).__init__(*args, **kwargs)
359 self.__has_signed_terms = self.has_signed_terms
360 if not self.id and not self.is_active:
361 self.is_active = False
365 return '%s %s' % (self.first_name, self.last_name)
368 def realname(self, value):
369 parts = value.split(' ')
371 self.first_name = parts[0]
372 self.last_name = parts[1]
374 self.last_name = parts[0]
376 def add_permission(self, pname):
377 if self.has_perm(pname):
379 p, created = Permission.objects.get_or_create(codename=pname,
380 name=pname.capitalize(),
381 content_type=content_type)
382 self.user_permissions.add(p)
384 def remove_permission(self, pname):
385 if self.has_perm(pname):
387 p = Permission.objects.get(codename=pname,
388 content_type=content_type)
389 self.user_permissions.remove(p)
392 def invitation(self):
394 return Invitation.objects.get(username=self.email)
395 except Invitation.DoesNotExist:
398 def invite(self, email, realname):
399 inv = Invitation(inviter=self, username=email, realname=realname)
402 self.invitations = max(0, self.invitations - 1)
407 """Returns a dict with the sum of quota limits per resource"""
409 for q in self.policies:
410 d[q.resource] += q.uplimit
411 for m in self.extended_groups:
412 if not m.is_approved:
417 for r, uplimit in g.quota.iteritems():
420 # TODO set default for remaining
425 return self.astakosuserquota_set.select_related().all()
428 def policies(self, policies):
430 service = policies.get('service', None)
431 resource = policies.get('resource', None)
432 uplimit = policies.get('uplimit', 0)
433 update = policies.get('update', True)
434 self.add_policy(service, resource, uplimit, update)
436 def add_policy(self, service, resource, uplimit, update=True):
437 """Raises ObjectDoesNotExist, IntegrityError"""
438 resource = Resource.objects.get(service__name=service, name=resource)
440 AstakosUserQuota.objects.update_or_create(user=self,
442 defaults={'uplimit': uplimit})
444 q = self.astakosuserquota_set
445 q.create(resource=resource, uplimit=uplimit)
447 def remove_policy(self, service, resource):
448 """Raises ObjectDoesNotExist, IntegrityError"""
449 resource = Resource.objects.get(service__name=service, name=resource)
450 q = self.policies.get(resource=resource).delete()
453 def extended_groups(self):
454 return self.membership_set.select_related().all()
456 @extended_groups.setter
457 def extended_groups(self, groups):
460 group = AstakosGroup.objects.get(name=name)
461 self.membership_set.create(group=group)
463 def save(self, update_timestamps=True, **kwargs):
464 if update_timestamps:
466 self.date_joined = datetime.now()
467 self.updated = datetime.now()
469 # update date_signed_terms if necessary
470 if self.__has_signed_terms != self.has_signed_terms:
471 self.date_signed_terms = datetime.now()
475 while not self.username:
476 username = uuid.uuid4().hex[:30]
478 AstakosUser.objects.get(username=username)
479 except AstakosUser.DoesNotExist:
480 self.username = username
481 if not self.provider:
482 self.provider = 'local'
483 self.email = self.email.lower()
484 self.validate_unique_email_isactive()
485 if self.is_active and self.activation_sent:
486 # reset the activation sent
487 self.activation_sent = None
489 super(AstakosUser, self).save(**kwargs)
491 def renew_token(self):
493 md5.update(self.username)
494 md5.update(self.realname.encode('ascii', 'ignore'))
495 md5.update(asctime())
497 self.auth_token = b64encode(md5.digest())
498 self.auth_token_created = datetime.now()
499 self.auth_token_expires = self.auth_token_created + \
500 timedelta(hours=AUTH_TOKEN_DURATION)
501 msg = 'Token renewed for %s' % self.email
502 logger.log(LOGGING_LEVEL, msg)
504 def __unicode__(self):
505 return '%s (%s)' % (self.realname, self.email)
507 def conflicting_email(self):
508 q = AstakosUser.objects.exclude(username=self.username)
509 q = q.filter(email=self.email)
514 def validate_unique_email_isactive(self):
516 Implements a unique_together constraint for email and is_active fields.
518 q = AstakosUser.objects.exclude(username=self.username)
519 q = q.filter(email=self.email)
520 q = q.filter(is_active=self.is_active)
522 raise ValidationError({'__all__': [_('Another account with the same email & is_active combination found.')]})
525 def signed_terms(self):
526 term = get_latest_terms()
529 if not self.has_signed_terms:
531 if not self.date_signed_terms:
533 if self.date_signed_terms < term.date:
534 self.has_signed_terms = False
535 self.date_signed_terms = None
540 def store_disturbed_quota(self, set=True):
541 self.disturbed_qutoa = set
545 class Membership(models.Model):
546 person = models.ForeignKey(AstakosUser)
547 group = models.ForeignKey(AstakosGroup)
548 date_requested = models.DateField(default=datetime.now(), blank=True)
549 date_joined = models.DateField(null=True, db_index=True, blank=True)
552 unique_together = ("person", "group")
554 def save(self, *args, **kwargs):
556 if not self.group.moderation_enabled:
557 self.date_joined = datetime.now()
558 super(Membership, self).save(*args, **kwargs)
561 def is_approved(self):
567 self.date_joined = datetime.now()
569 quota_disturbed.send(sender=self, users=(self.person,))
571 def disapprove(self):
573 quota_disturbed.send(sender=self, users=(self.person,))
575 class AstakosQuotaManager(models.Manager):
576 def _update_or_create(self, **kwargs):
578 'update_or_create() must be passed at least one keyword argument'
579 obj, created = self.get_or_create(**kwargs)
580 defaults = kwargs.pop('defaults', {})
582 return obj, True, False
586 [(k, v) for k, v in kwargs.items() if '__' not in k])
587 params.update(defaults)
588 for attr, val in params.items():
589 if hasattr(obj, attr):
590 setattr(obj, attr, val)
591 sid = transaction.savepoint()
592 obj.save(force_update=True)
593 transaction.savepoint_commit(sid)
594 return obj, False, True
595 except IntegrityError, e:
596 transaction.savepoint_rollback(sid)
598 return self.get(**kwargs), False, False
599 except self.model.DoesNotExist:
602 update_or_create = _update_or_create
604 class AstakosGroupQuota(models.Model):
605 objects = AstakosQuotaManager()
606 limit = models.PositiveIntegerField('Limit', null=True) # obsolete field
607 uplimit = models.BigIntegerField('Up limit', null=True)
608 resource = models.ForeignKey(Resource)
609 group = models.ForeignKey(AstakosGroup, blank=True)
612 unique_together = ("resource", "group")
614 class AstakosUserQuota(models.Model):
615 objects = AstakosQuotaManager()
616 limit = models.PositiveIntegerField('Limit', null=True) # obsolete field
617 uplimit = models.BigIntegerField('Up limit', null=True)
618 resource = models.ForeignKey(Resource)
619 user = models.ForeignKey(AstakosUser)
622 unique_together = ("resource", "user")
625 class ApprovalTerms(models.Model):
627 Model for approval terms
630 date = models.DateTimeField(
631 'Issue date', db_index=True, default=datetime.now())
632 location = models.CharField('Terms location', max_length=255)
635 class Invitation(models.Model):
637 Model for registring invitations
639 inviter = models.ForeignKey(AstakosUser, related_name='invitations_sent',
641 realname = models.CharField('Real name', max_length=255)
642 username = models.CharField('Unique ID', max_length=255, unique=True)
643 code = models.BigIntegerField('Invitation code', db_index=True)
644 is_consumed = models.BooleanField('Consumed?', default=False)
645 created = models.DateTimeField('Creation date', auto_now_add=True)
646 consumed = models.DateTimeField('Consumption date', null=True, blank=True)
648 def __init__(self, *args, **kwargs):
649 super(Invitation, self).__init__(*args, **kwargs)
651 self.code = _generate_invitation_code()
654 self.is_consumed = True
655 self.consumed = datetime.now()
658 def __unicode__(self):
659 return '%s -> %s [%d]' % (self.inviter, self.username, self.code)
662 class EmailChangeManager(models.Manager):
663 @transaction.commit_on_success
664 def change_email(self, activation_key):
666 Validate an activation key and change the corresponding
669 If the key is valid and has not expired, return the ``User``
672 If the key is not valid or has expired, return ``None``.
674 If the key is valid but the ``User`` is already active,
677 After successful email change the activation record is deleted.
679 Throws ValueError if there is already
682 email_change = self.model.objects.get(
683 activation_key=activation_key)
684 if email_change.activation_key_expired():
685 email_change.delete()
686 raise EmailChange.DoesNotExist
687 # is there an active user with this address?
689 AstakosUser.objects.get(email=email_change.new_email_address)
690 except AstakosUser.DoesNotExist:
693 raise ValueError(_('The new email address is reserved.'))
695 user = AstakosUser.objects.get(pk=email_change.user_id)
696 user.email = email_change.new_email_address
698 email_change.delete()
700 except EmailChange.DoesNotExist:
701 raise ValueError(_('Invalid activation key'))
704 class EmailChange(models.Model):
705 new_email_address = models.EmailField(_(u'new e-mail address'),
706 help_text=_(u'Your old email address will be used until you verify your new one.'))
707 user = models.ForeignKey(
708 AstakosUser, unique=True, related_name='emailchange_user')
709 requested_at = models.DateTimeField(default=datetime.now())
710 activation_key = models.CharField(
711 max_length=40, unique=True, db_index=True)
713 objects = EmailChangeManager()
715 def activation_key_expired(self):
716 expiration_date = timedelta(days=EMAILCHANGE_ACTIVATION_DAYS)
717 return self.requested_at + expiration_date < datetime.now()
720 class AdditionalMail(models.Model):
722 Model for registring invitations
724 owner = models.ForeignKey(AstakosUser)
725 email = models.EmailField()
728 def _generate_invitation_code():
730 code = randint(1, 2L ** 63 - 1)
732 Invitation.objects.get(code=code)
733 # An invitation with this code already exists, try again
734 except Invitation.DoesNotExist:
738 def get_latest_terms():
740 term = ApprovalTerms.objects.order_by('-id')[0]
747 def create_astakos_user(u):
749 AstakosUser.objects.get(user_ptr=u.pk)
750 except AstakosUser.DoesNotExist:
751 extended_user = AstakosUser(user_ptr_id=u.pk)
752 extended_user.__dict__.update(u.__dict__)
753 extended_user.renew_token()
755 except BaseException, e:
760 def fix_superusers(sender, **kwargs):
761 # Associate superusers with AstakosUser
762 admins = User.objects.filter(is_superuser=True)
764 create_astakos_user(u)
767 def user_post_save(sender, instance, created, **kwargs):
770 create_astakos_user(instance)
773 def set_default_group(user):
775 default = AstakosGroup.objects.get(name='default')
777 group=default, person=user, date_joined=datetime.now()).save()
778 except AstakosGroup.DoesNotExist, e:
782 def astakosuser_pre_save(sender, instance, **kwargs):
783 instance.aquarium_report = False
786 db_instance = AstakosUser.objects.get(id=instance.id)
787 except AstakosUser.DoesNotExist:
789 instance.aquarium_report = True
792 get = AstakosUser.__getattribute__
793 l = filter(lambda f: get(db_instance, f) != get(instance, f),
795 instance.aquarium_report = True if l else False
798 def astakosuser_post_save(sender, instance, created, **kwargs):
799 if instance.aquarium_report:
800 report_user_event(instance, create=instance.new)
803 set_default_group(instance)
804 # TODO handle socket.error & IOError
805 register_users((instance,))
806 instance.renew_token()
809 def resource_post_save(sender, instance, created, **kwargs):
812 register_resources((instance,))
815 def send_quota_disturbed(sender, instance, **kwargs):
817 extend = users.extend
818 if sender == Membership:
819 if not instance.group.is_enabled:
821 extend([instance.person])
822 elif sender == AstakosUserQuota:
823 extend([instance.user])
824 elif sender == AstakosGroupQuota:
825 if not instance.group.is_enabled:
827 extend(instance.group.astakosuser_set.all())
828 elif sender == AstakosGroup:
829 if not instance.is_enabled:
831 quota_disturbed.send(sender=sender, users=users)
834 def on_quota_disturbed(sender, users, **kwargs):
835 print '>>>', locals()
840 post_syncdb.connect(fix_superusers)
841 post_save.connect(user_post_save, sender=User)
842 pre_save.connect(astakosuser_pre_save, sender=AstakosUser)
843 post_save.connect(astakosuser_post_save, sender=AstakosUser)
844 post_save.connect(resource_post_save, sender=Resource)
846 quota_disturbed = Signal(providing_args=["users"])
847 quota_disturbed.connect(on_quota_disturbed)
849 post_delete.connect(send_quota_disturbed, sender=AstakosGroup)
850 post_delete.connect(send_quota_disturbed, sender=Membership)
851 post_save.connect(send_quota_disturbed, sender=AstakosUserQuota)
852 post_delete.connect(send_quota_disturbed, sender=AstakosUserQuota)
853 post_save.connect(send_quota_disturbed, sender=AstakosGroupQuota)
854 post_delete.connect(send_quota_disturbed, sender=AstakosGroupQuota)