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.db import transaction
48 from django.core.exceptions import ValidationError
49 from django.db import transaction
50 from django.db.models.signals import (pre_save, post_save, post_syncdb,
52 from django.contrib.contenttypes.models import ContentType
54 from django.dispatch import Signal
55 from django.db.models import Q
57 from astakos.im.settings import (DEFAULT_USER_LEVEL, INVITATIONS_PER_LEVEL,
58 AUTH_TOKEN_DURATION, BILLING_FIELDS,
59 EMAILCHANGE_ACTIVATION_DAYS, LOGGING_LEVEL)
60 from astakos.im.endpoints.qh import (register_users, send_quota,
62 from astakos.im.endpoints.aquarium.producer import report_user_event
63 from astakos.im.functions import send_invitation
64 from astakos.im.tasks import propagate_groupmembers_quota
65 from astakos.im.functions import send_invitation
67 import astakos.im.messages as astakos_messages
69 logger = logging.getLogger(__name__)
71 DEFAULT_CONTENT_TYPE = None
73 content_type = ContentType.objects.get(app_label='im', model='astakosuser')
75 content_type = DEFAULT_CONTENT_TYPE
77 RESOURCE_SEPARATOR = '.'
81 class Service(models.Model):
82 name = models.CharField('Name', max_length=255, unique=True, db_index=True)
83 url = models.FilePathField()
84 icon = models.FilePathField(blank=True)
85 auth_token = models.CharField('Authentication Token', max_length=32,
86 null=True, blank=True)
87 auth_token_created = models.DateTimeField('Token creation date', null=True)
88 auth_token_expires = models.DateTimeField(
89 'Token expiration date', null=True)
91 def save(self, **kwargs):
94 super(Service, self).save(**kwargs)
96 def renew_token(self):
98 md5.update(self.name.encode('ascii', 'ignore'))
99 md5.update(self.url.encode('ascii', 'ignore'))
100 md5.update(asctime())
102 self.auth_token = b64encode(md5.digest())
103 self.auth_token_created = datetime.now()
104 self.auth_token_expires = self.auth_token_created + \
105 timedelta(hours=AUTH_TOKEN_DURATION)
112 return self.resource_set.all()
115 def resources(self, resources):
117 self.resource_set.create(**s)
119 def add_resource(self, service, resource, uplimit, update=True):
120 """Raises ObjectDoesNotExist, IntegrityError"""
121 resource = Resource.objects.get(service__name=service, name=resource)
123 AstakosUserQuota.objects.update_or_create(user=self,
125 defaults={'uplimit': uplimit})
127 q = self.astakosuserquota_set
128 q.create(resource=resource, uplimit=uplimit)
131 class ResourceMetadata(models.Model):
132 key = models.CharField('Name', max_length=255, unique=True, db_index=True)
133 value = models.CharField('Value', max_length=255)
136 class Resource(models.Model):
137 name = models.CharField('Name', max_length=255, unique=True, db_index=True)
138 meta = models.ManyToManyField(ResourceMetadata)
139 service = models.ForeignKey(Service)
140 desc = models.TextField('Description', null=True)
141 unit = models.CharField('Name', null=True, max_length=255)
142 group = models.CharField('Group', null=True, max_length=255)
145 return '%s%s%s' % (self.service, RESOURCE_SEPARATOR, self.name)
148 class GroupKind(models.Model):
149 name = models.CharField('Name', max_length=255, unique=True, db_index=True)
155 class AstakosGroup(Group):
156 kind = models.ForeignKey(GroupKind)
157 homepage = models.URLField(
158 'Homepage Url', max_length=255, null=True, blank=True)
159 desc = models.TextField('Description', null=True)
160 policy = models.ManyToManyField(
164 through='AstakosGroupQuota'
166 creation_date = models.DateTimeField(
168 default=datetime.now()
170 issue_date = models.DateTimeField('Issue date', null=True)
171 expiration_date = models.DateTimeField(
175 moderation_enabled = models.BooleanField(
176 'Moderated membership?',
179 approval_date = models.DateTimeField(
184 estimated_participants = models.PositiveIntegerField(
185 'Estimated #members',
189 max_participants = models.PositiveIntegerField(
190 'Maximum numder of participants',
196 def is_disabled(self):
197 if not self.approval_date:
202 def is_enabled(self):
205 if not self.issue_date:
207 if not self.expiration_date:
210 if self.issue_date > now:
212 if now >= self.expiration_date:
219 self.approval_date = datetime.now()
221 quota_disturbed.send(sender=self, users=self.approved_members)
222 propagate_groupmembers_quota.apply_async(
223 args=[self], eta=self.issue_date)
224 propagate_groupmembers_quota.apply_async(
225 args=[self], eta=self.expiration_date)
230 self.approval_date = None
232 quota_disturbed.send(sender=self, users=self.approved_members)
234 @transaction.commit_manually
235 def approve_member(self, person):
236 m, created = self.membership_set.get_or_create(person=person)
237 # update date_joined in any case
241 transaction.rollback()
246 # def disapprove_member(self, person):
247 # self.membership_set.remove(person=person)
251 q = self.membership_set.select_related().all()
252 return [m.person for m in q]
255 def approved_members(self):
256 q = self.membership_set.select_related().all()
257 return [m.person for m in q if m.is_approved]
262 for q in self.astakosgroupquota_set.select_related().all():
263 d[q.resource] += q.uplimit or inf
266 def add_policy(self, service, resource, uplimit, update=True):
267 """Raises ObjectDoesNotExist, IntegrityError"""
269 resource = Resource.objects.get(service__name=service, name=resource)
271 AstakosGroupQuota.objects.update_or_create(
274 defaults={'uplimit': uplimit}
277 q = self.astakosgroupquota_set
278 q.create(resource=resource, uplimit=uplimit)
282 return self.astakosgroupquota_set.select_related().all()
285 def policies(self, policies):
287 service = p.get('service', None)
288 resource = p.get('resource', None)
289 uplimit = p.get('uplimit', 0)
290 update = p.get('update', True)
291 self.add_policy(service, resource, uplimit, update)
295 return self.owner.all()
298 def owner_details(self):
299 return self.owner.select_related().all()
304 map(self.approve_member, l)
307 class AstakosUser(User):
309 Extends ``django.contrib.auth.models.User`` by defining additional fields.
311 # Use UserManager to get the create_user method, etc.
312 objects = UserManager()
314 affiliation = models.CharField('Affiliation', max_length=255, blank=True)
315 provider = models.CharField('Provider', max_length=255, blank=True)
318 user_level = DEFAULT_USER_LEVEL
319 level = models.IntegerField('Inviter level', default=user_level)
320 invitations = models.IntegerField(
321 'Invitations left', default=INVITATIONS_PER_LEVEL.get(user_level, 0))
323 auth_token = models.CharField('Authentication Token', max_length=32,
324 null=True, blank=True)
325 auth_token_created = models.DateTimeField('Token creation date', null=True)
326 auth_token_expires = models.DateTimeField(
327 'Token expiration date', null=True)
329 updated = models.DateTimeField('Update date')
330 is_verified = models.BooleanField('Is verified?', default=False)
332 # ex. screen_name for twitter, eppn for shibboleth
333 third_party_identifier = models.CharField(
334 'Third-party identifier', max_length=255, null=True, blank=True)
336 email_verified = models.BooleanField('Email verified?', default=False)
338 has_credits = models.BooleanField('Has credits?', default=False)
339 has_signed_terms = models.BooleanField(
340 'I agree with the terms', default=False)
341 date_signed_terms = models.DateTimeField(
342 'Signed terms date', null=True, blank=True)
344 activation_sent = models.DateTimeField(
345 'Activation sent data', null=True, blank=True)
347 policy = models.ManyToManyField(
348 Resource, null=True, through='AstakosUserQuota')
350 astakos_groups = models.ManyToManyField(
351 AstakosGroup, verbose_name=_('agroups'), blank=True,
352 help_text=_(astakos_messages.ASTAKOSUSER_GROUPS_HELP),
353 through='Membership')
355 __has_signed_terms = False
356 disturbed_quota = models.BooleanField('Needs quotaholder syncing',
357 default=False, db_index=True)
359 owner = models.ManyToManyField(
360 AstakosGroup, related_name='owner', null=True)
363 unique_together = ("provider", "third_party_identifier")
365 def __init__(self, *args, **kwargs):
366 super(AstakosUser, self).__init__(*args, **kwargs)
367 self.__has_signed_terms = self.has_signed_terms
369 self.is_active = False
373 return '%s %s' % (self.first_name, self.last_name)
376 def realname(self, value):
377 parts = value.split(' ')
379 self.first_name = parts[0]
380 self.last_name = parts[1]
382 self.last_name = parts[0]
384 def add_permission(self, pname):
385 if self.has_perm(pname):
387 p, created = Permission.objects.get_or_create(codename=pname,
388 name=pname.capitalize(),
389 content_type=content_type)
390 self.user_permissions.add(p)
392 def remove_permission(self, pname):
393 if self.has_perm(pname):
395 p = Permission.objects.get(codename=pname,
396 content_type=content_type)
397 self.user_permissions.remove(p)
400 def invitation(self):
402 return Invitation.objects.get(username=self.email)
403 except Invitation.DoesNotExist:
406 def invite(self, email, realname):
407 inv = Invitation(inviter=self, username=email, realname=realname)
410 self.invitations = max(0, self.invitations - 1)
415 """Returns a dict with the sum of quota limits per resource"""
417 for q in self.policies:
418 d[q.resource] += q.uplimit or inf
419 for m in self.extended_groups:
420 if not m.is_approved:
425 for r, uplimit in g.quota.iteritems():
426 d[r] += uplimit or inf
427 # TODO set default for remaining
432 return self.astakosuserquota_set.select_related().all()
435 def policies(self, policies):
437 service = policies.get('service', None)
438 resource = policies.get('resource', None)
439 uplimit = policies.get('uplimit', 0)
440 update = policies.get('update', True)
441 self.add_policy(service, resource, uplimit, update)
443 def add_policy(self, service, resource, uplimit, update=True):
444 """Raises ObjectDoesNotExist, IntegrityError"""
445 resource = Resource.objects.get(service__name=service, name=resource)
447 AstakosUserQuota.objects.update_or_create(user=self,
449 defaults={'uplimit': uplimit})
451 q = self.astakosuserquota_set
452 q.create(resource=resource, uplimit=uplimit)
454 def remove_policy(self, service, resource):
455 """Raises ObjectDoesNotExist, IntegrityError"""
456 resource = Resource.objects.get(service__name=service, name=resource)
457 q = self.policies.get(resource=resource).delete()
460 def extended_groups(self):
461 return self.membership_set.select_related().all()
463 @extended_groups.setter
464 def extended_groups(self, groups):
466 for name in (groups or ()):
467 group = AstakosGroup.objects.get(name=name)
468 self.membership_set.create(group=group)
470 def save(self, update_timestamps=True, **kwargs):
471 if update_timestamps:
473 self.date_joined = datetime.now()
474 self.updated = datetime.now()
476 # update date_signed_terms if necessary
477 if self.__has_signed_terms != self.has_signed_terms:
478 self.date_signed_terms = datetime.now()
482 while not self.username:
483 username = uuid.uuid4().hex[:30]
485 AstakosUser.objects.get(username=username)
486 except AstakosUser.DoesNotExist:
487 self.username = username
488 if not self.provider:
489 self.provider = 'local'
490 self.email = self.email.lower()
491 self.validate_unique_email_isactive()
492 if self.is_active and self.activation_sent:
493 # reset the activation sent
494 self.activation_sent = None
496 super(AstakosUser, self).save(**kwargs)
498 def renew_token(self):
500 md5.update(self.username)
501 md5.update(self.realname.encode('ascii', 'ignore'))
502 md5.update(asctime())
504 self.auth_token = b64encode(md5.digest())
505 self.auth_token_created = datetime.now()
506 self.auth_token_expires = self.auth_token_created + \
507 timedelta(hours=AUTH_TOKEN_DURATION)
508 msg = 'Token renewed for %s' % self.email
509 logger.log(LOGGING_LEVEL, msg)
511 def __unicode__(self):
512 return '%s (%s)' % (self.realname, self.email)
514 def conflicting_email(self):
515 q = AstakosUser.objects.exclude(username=self.username)
516 q = q.filter(email=self.email)
521 def validate_unique_email_isactive(self):
523 Implements a unique_together constraint for email and is_active fields.
525 q = AstakosUser.objects.exclude(username=self.username)
526 q = q.filter(email=self.email)
527 q = q.filter(is_active=self.is_active)
529 raise ValidationError({'__all__': [_(astakos_messages.UNIQUE_EMAIL_IS_ACTIVE_CONSTRAIN_ERR)]})
532 def signed_terms(self):
533 term = get_latest_terms()
536 if not self.has_signed_terms:
538 if not self.date_signed_terms:
540 if self.date_signed_terms < term.date:
541 self.has_signed_terms = False
542 self.date_signed_terms = None
547 def store_disturbed_quota(self, set=True):
548 self.disturbed_qutoa = set
552 class Membership(models.Model):
553 person = models.ForeignKey(AstakosUser)
554 group = models.ForeignKey(AstakosGroup)
555 date_requested = models.DateField(default=datetime.now(), blank=True)
556 date_joined = models.DateField(null=True, db_index=True, blank=True)
559 unique_together = ("person", "group")
561 def save(self, *args, **kwargs):
563 if not self.group.moderation_enabled:
564 self.date_joined = datetime.now()
565 super(Membership, self).save(*args, **kwargs)
568 def is_approved(self):
576 if self.group.max_participants:
577 assert len(self.group.approved_members) + 1 <= self.group.max_participants, \
578 'Maximum participant number has been reached.'
579 self.date_joined = datetime.now()
581 quota_disturbed.send(sender=self, users=(self.person,))
583 def disapprove(self):
585 quota_disturbed.send(sender=self, users=(self.person,))
587 class AstakosQuotaManager(models.Manager):
588 def _update_or_create(self, **kwargs):
590 'update_or_create() must be passed at least one keyword argument'
591 obj, created = self.get_or_create(**kwargs)
592 defaults = kwargs.pop('defaults', {})
594 return obj, True, False
598 [(k, v) for k, v in kwargs.items() if '__' not in k])
599 params.update(defaults)
600 for attr, val in params.items():
601 if hasattr(obj, attr):
602 setattr(obj, attr, val)
603 sid = transaction.savepoint()
604 obj.save(force_update=True)
605 transaction.savepoint_commit(sid)
606 return obj, False, True
607 except IntegrityError, e:
608 transaction.savepoint_rollback(sid)
610 return self.get(**kwargs), False, False
611 except self.model.DoesNotExist:
614 update_or_create = _update_or_create
616 class AstakosGroupQuota(models.Model):
617 objects = AstakosQuotaManager()
618 limit = models.PositiveIntegerField('Limit', null=True) # obsolete field
619 uplimit = models.BigIntegerField('Up limit', null=True)
620 resource = models.ForeignKey(Resource)
621 group = models.ForeignKey(AstakosGroup, blank=True)
624 unique_together = ("resource", "group")
626 class AstakosUserQuota(models.Model):
627 objects = AstakosQuotaManager()
628 limit = models.PositiveIntegerField('Limit', null=True) # obsolete field
629 uplimit = models.BigIntegerField('Up limit', null=True)
630 resource = models.ForeignKey(Resource)
631 user = models.ForeignKey(AstakosUser)
634 unique_together = ("resource", "user")
637 class ApprovalTerms(models.Model):
639 Model for approval terms
642 date = models.DateTimeField(
643 'Issue date', db_index=True, default=datetime.now())
644 location = models.CharField('Terms location', max_length=255)
647 class Invitation(models.Model):
649 Model for registring invitations
651 inviter = models.ForeignKey(AstakosUser, related_name='invitations_sent',
653 realname = models.CharField('Real name', max_length=255)
654 username = models.CharField('Unique ID', max_length=255, unique=True)
655 code = models.BigIntegerField('Invitation code', db_index=True)
656 is_consumed = models.BooleanField('Consumed?', default=False)
657 created = models.DateTimeField('Creation date', auto_now_add=True)
658 consumed = models.DateTimeField('Consumption date', null=True, blank=True)
660 def __init__(self, *args, **kwargs):
661 super(Invitation, self).__init__(*args, **kwargs)
663 self.code = _generate_invitation_code()
666 self.is_consumed = True
667 self.consumed = datetime.now()
670 def __unicode__(self):
671 return '%s -> %s [%d]' % (self.inviter, self.username, self.code)
674 class EmailChangeManager(models.Manager):
675 @transaction.commit_on_success
676 def change_email(self, activation_key):
678 Validate an activation key and change the corresponding
681 If the key is valid and has not expired, return the ``User``
684 If the key is not valid or has expired, return ``None``.
686 If the key is valid but the ``User`` is already active,
689 After successful email change the activation record is deleted.
691 Throws ValueError if there is already
694 email_change = self.model.objects.get(
695 activation_key=activation_key)
696 if email_change.activation_key_expired():
697 email_change.delete()
698 raise EmailChange.DoesNotExist
699 # is there an active user with this address?
701 AstakosUser.objects.get(email=email_change.new_email_address)
702 except AstakosUser.DoesNotExist:
705 raise ValueError(_(astakos_messages.NEW_EMAIL_ADDR_RESERVED))
707 user = AstakosUser.objects.get(pk=email_change.user_id)
708 user.email = email_change.new_email_address
710 email_change.delete()
712 except EmailChange.DoesNotExist:
713 raise ValueError(_(astakos_messages.INVALID_ACTIVATION_KEY))
716 class EmailChange(models.Model):
717 new_email_address = models.EmailField(_(u'new e-mail address'),
718 help_text=_(astakos_messages.EMAIL_CHANGE_NEW_ADDR_HELP))
719 user = models.ForeignKey(
720 AstakosUser, unique=True, related_name='emailchange_user')
721 requested_at = models.DateTimeField(default=datetime.now())
722 activation_key = models.CharField(
723 max_length=40, unique=True, db_index=True)
725 objects = EmailChangeManager()
727 def activation_key_expired(self):
728 expiration_date = timedelta(days=EMAILCHANGE_ACTIVATION_DAYS)
729 return self.requested_at + expiration_date < datetime.now()
732 class AdditionalMail(models.Model):
734 Model for registring invitations
736 owner = models.ForeignKey(AstakosUser)
737 email = models.EmailField()
740 def _generate_invitation_code():
742 code = randint(1, 2L ** 63 - 1)
744 Invitation.objects.get(code=code)
745 # An invitation with this code already exists, try again
746 except Invitation.DoesNotExist:
750 def get_latest_terms():
752 term = ApprovalTerms.objects.order_by('-id')[0]
759 def create_astakos_user(u):
761 AstakosUser.objects.get(user_ptr=u.pk)
762 except AstakosUser.DoesNotExist:
763 extended_user = AstakosUser(user_ptr_id=u.pk)
764 extended_user.__dict__.update(u.__dict__)
765 # extended_user.renew_token()
767 except BaseException, e:
772 def fix_superusers(sender, **kwargs):
773 # Associate superusers with AstakosUser
774 admins = User.objects.filter(is_superuser=True)
776 create_astakos_user(u)
779 def user_post_save(sender, instance, created, **kwargs):
782 create_astakos_user(instance)
785 def set_default_group(user):
787 default = AstakosGroup.objects.get(name='default')
789 group=default, person=user, date_joined=datetime.now()).save()
790 except AstakosGroup.DoesNotExist, e:
794 def astakosuser_pre_save(sender, instance, **kwargs):
795 instance.aquarium_report = False
798 db_instance = AstakosUser.objects.get(id=instance.id)
799 except AstakosUser.DoesNotExist:
801 instance.aquarium_report = True
804 get = AstakosUser.__getattribute__
805 l = filter(lambda f: get(db_instance, f) != get(instance, f),
807 instance.aquarium_report = True if l else False
810 def astakosuser_post_save(sender, instance, created, **kwargs):
811 if instance.aquarium_report:
812 report_user_event(instance, create=instance.new)
815 set_default_group(instance)
816 # TODO handle socket.error & IOError
817 register_users((instance,))
818 instance.renew_token()
821 def resource_post_save(sender, instance, created, **kwargs):
824 register_resources((instance,))
827 def send_quota_disturbed(sender, instance, **kwargs):
829 extend = users.extend
830 if sender == Membership:
831 if not instance.group.is_enabled:
833 extend([instance.person])
834 elif sender == AstakosUserQuota:
835 extend([instance.user])
836 elif sender == AstakosGroupQuota:
837 if not instance.group.is_enabled:
839 extend(instance.group.astakosuser_set.all())
840 elif sender == AstakosGroup:
841 if not instance.is_enabled:
843 quota_disturbed.send(sender=sender, users=users)
846 def on_quota_disturbed(sender, users, **kwargs):
847 print '>>>', locals()
852 post_syncdb.connect(fix_superusers)
853 post_save.connect(user_post_save, sender=User)
854 pre_save.connect(astakosuser_pre_save, sender=AstakosUser)
855 post_save.connect(astakosuser_post_save, sender=AstakosUser)
856 post_save.connect(resource_post_save, sender=Resource)
858 quota_disturbed = Signal(providing_args=["users"])
859 quota_disturbed.connect(on_quota_disturbed)
861 post_delete.connect(send_quota_disturbed, sender=AstakosGroup)
862 post_delete.connect(send_quota_disturbed, sender=Membership)
863 post_save.connect(send_quota_disturbed, sender=AstakosUserQuota)
864 post_delete.connect(send_quota_disturbed, sender=AstakosUserQuota)
865 post_save.connect(send_quota_disturbed, sender=AstakosGroupQuota)
866 post_delete.connect(send_quota_disturbed, sender=AstakosGroupQuota)