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 import astakos.im.messages as astakos_messages
68 logger = logging.getLogger(__name__)
70 DEFAULT_CONTENT_TYPE = None
72 content_type = ContentType.objects.get(app_label='im', model='astakosuser')
74 content_type = DEFAULT_CONTENT_TYPE
76 RESOURCE_SEPARATOR = '.'
80 class Service(models.Model):
81 name = models.CharField('Name', max_length=255, unique=True, db_index=True)
82 url = models.FilePathField()
83 icon = models.FilePathField(blank=True)
84 auth_token = models.CharField('Authentication Token', max_length=32,
85 null=True, blank=True)
86 auth_token_created = models.DateTimeField('Token creation date', null=True)
87 auth_token_expires = models.DateTimeField(
88 'Token expiration date', null=True)
90 def save(self, **kwargs):
93 super(Service, self).save(**kwargs)
95 def renew_token(self):
97 md5.update(self.name.encode('ascii', 'ignore'))
98 md5.update(self.url.encode('ascii', 'ignore'))
101 self.auth_token = b64encode(md5.digest())
102 self.auth_token_created = datetime.now()
103 self.auth_token_expires = self.auth_token_created + \
104 timedelta(hours=AUTH_TOKEN_DURATION)
111 return self.resource_set.all()
114 def resources(self, resources):
116 self.resource_set.create(**s)
118 def add_resource(self, service, resource, uplimit, update=True):
119 """Raises ObjectDoesNotExist, IntegrityError"""
120 resource = Resource.objects.get(service__name=service, name=resource)
122 AstakosUserQuota.objects.update_or_create(user=self,
124 defaults={'uplimit': uplimit})
126 q = self.astakosuserquota_set
127 q.create(resource=resource, uplimit=uplimit)
130 class ResourceMetadata(models.Model):
131 key = models.CharField('Name', max_length=255, unique=True, db_index=True)
132 value = models.CharField('Value', max_length=255)
135 class Resource(models.Model):
136 name = models.CharField('Name', max_length=255, unique=True, db_index=True)
137 meta = models.ManyToManyField(ResourceMetadata)
138 service = models.ForeignKey(Service)
139 desc = models.TextField('Description', null=True)
140 unit = models.CharField('Name', null=True, max_length=255)
141 group = models.CharField('Group', null=True, max_length=255)
144 return '%s%s%s' % (self.service, RESOURCE_SEPARATOR, self.name)
147 class GroupKind(models.Model):
148 name = models.CharField('Name', max_length=255, unique=True, db_index=True)
154 class AstakosGroup(Group):
155 kind = models.ForeignKey(GroupKind)
156 homepage = models.URLField(
157 'Homepage Url', max_length=255, null=True, blank=True)
158 desc = models.TextField('Description', null=True)
159 policy = models.ManyToManyField(
163 through='AstakosGroupQuota'
165 creation_date = models.DateTimeField(
167 default=datetime.now()
169 issue_date = models.DateTimeField('Issue date', null=True)
170 expiration_date = models.DateTimeField(
174 moderation_enabled = models.BooleanField(
175 'Moderated membership?',
178 approval_date = models.DateTimeField(
183 estimated_participants = models.PositiveIntegerField(
184 'Estimated #members',
188 max_participants = models.PositiveIntegerField(
189 'Maximum numder of participants',
195 def is_disabled(self):
196 if not self.approval_date:
201 def is_enabled(self):
204 if not self.issue_date:
206 if not self.expiration_date:
209 if self.issue_date > now:
211 if now >= self.expiration_date:
218 self.approval_date = datetime.now()
220 quota_disturbed.send(sender=self, users=self.approved_members)
221 propagate_groupmembers_quota.apply_async(
222 args=[self], eta=self.issue_date)
223 propagate_groupmembers_quota.apply_async(
224 args=[self], eta=self.expiration_date)
229 self.approval_date = None
231 quota_disturbed.send(sender=self, users=self.approved_members)
233 def approve_member(self, person):
234 m, created = self.membership_set.get_or_create(person=person)
235 # update date_joined in any case
236 m.date_joined = datetime.now()
239 def disapprove_member(self, person):
240 self.membership_set.remove(person=person)
244 q = self.membership_set.select_related().all()
245 return [m.person for m in q]
248 def approved_members(self):
249 q = self.membership_set.select_related().all()
250 return [m.person for m in q if m.is_approved]
255 for q in self.astakosgroupquota_set.select_related().all():
256 d[q.resource] += q.uplimit or inf
259 def add_policy(self, service, resource, uplimit, update=True):
260 """Raises ObjectDoesNotExist, IntegrityError"""
262 resource = Resource.objects.get(service__name=service, name=resource)
264 AstakosGroupQuota.objects.update_or_create(
267 defaults={'uplimit': uplimit}
270 q = self.astakosgroupquota_set
271 q.create(resource=resource, uplimit=uplimit)
275 return self.astakosgroupquota_set.select_related().all()
278 def policies(self, policies):
280 service = p.get('service', None)
281 resource = p.get('resource', None)
282 uplimit = p.get('uplimit', 0)
283 update = p.get('update', True)
284 self.add_policy(service, resource, uplimit, update)
288 return self.owner.all()
291 def owner_details(self):
292 return self.owner.select_related().all()
297 map(self.approve_member, l)
300 class AstakosUser(User):
302 Extends ``django.contrib.auth.models.User`` by defining additional fields.
304 # Use UserManager to get the create_user method, etc.
305 objects = UserManager()
307 affiliation = models.CharField('Affiliation', max_length=255, blank=True)
308 provider = models.CharField('Provider', max_length=255, blank=True)
311 user_level = DEFAULT_USER_LEVEL
312 level = models.IntegerField('Inviter level', default=user_level)
313 invitations = models.IntegerField(
314 'Invitations left', default=INVITATIONS_PER_LEVEL.get(user_level, 0))
316 auth_token = models.CharField('Authentication Token', max_length=32,
317 null=True, blank=True)
318 auth_token_created = models.DateTimeField('Token creation date', null=True)
319 auth_token_expires = models.DateTimeField(
320 'Token expiration date', null=True)
322 updated = models.DateTimeField('Update date')
323 is_verified = models.BooleanField('Is verified?', default=False)
325 # ex. screen_name for twitter, eppn for shibboleth
326 third_party_identifier = models.CharField(
327 'Third-party identifier', max_length=255, null=True, blank=True)
329 email_verified = models.BooleanField('Email verified?', default=False)
331 has_credits = models.BooleanField('Has credits?', default=False)
332 has_signed_terms = models.BooleanField(
333 'I agree with the terms', default=False)
334 date_signed_terms = models.DateTimeField(
335 'Signed terms date', null=True, blank=True)
337 activation_sent = models.DateTimeField(
338 'Activation sent data', null=True, blank=True)
340 policy = models.ManyToManyField(
341 Resource, null=True, through='AstakosUserQuota')
343 astakos_groups = models.ManyToManyField(
344 AstakosGroup, verbose_name=_('agroups'), blank=True,
345 help_text=_(astakos_messages.ASTAKOSUSER_GROUPS_HELP),
346 through='Membership')
348 __has_signed_terms = False
349 disturbed_quota = models.BooleanField('Needs quotaholder syncing',
350 default=False, db_index=True)
352 owner = models.ManyToManyField(
353 AstakosGroup, related_name='owner', null=True)
356 unique_together = ("provider", "third_party_identifier")
358 def __init__(self, *args, **kwargs):
359 super(AstakosUser, self).__init__(*args, **kwargs)
360 self.__has_signed_terms = self.has_signed_terms
362 self.is_active = False
366 return '%s %s' % (self.first_name, self.last_name)
369 def realname(self, value):
370 parts = value.split(' ')
372 self.first_name = parts[0]
373 self.last_name = parts[1]
375 self.last_name = parts[0]
377 def add_permission(self, pname):
378 if self.has_perm(pname):
380 p, created = Permission.objects.get_or_create(codename=pname,
381 name=pname.capitalize(),
382 content_type=content_type)
383 self.user_permissions.add(p)
385 def remove_permission(self, pname):
386 if self.has_perm(pname):
388 p = Permission.objects.get(codename=pname,
389 content_type=content_type)
390 self.user_permissions.remove(p)
393 def invitation(self):
395 return Invitation.objects.get(username=self.email)
396 except Invitation.DoesNotExist:
399 def invite(self, email, realname):
400 inv = Invitation(inviter=self, username=email, realname=realname)
403 self.invitations = max(0, self.invitations - 1)
408 """Returns a dict with the sum of quota limits per resource"""
410 for q in self.policies:
411 d[q.resource] += q.uplimit or inf
412 for m in self.extended_groups:
413 if not m.is_approved:
418 for r, uplimit in g.quota.iteritems():
419 d[r] += uplimit or inf
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):
459 for name in (groups or ()):
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__': [_(astakos_messages.UNIQUE_EMAIL_IS_ACTIVE_CONSTRAIN_ERR)]})
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(_(astakos_messages.NEW_EMAIL_ADDR_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(_(astakos_messages.INVALID_ACTIVATION_KEY))
704 class EmailChange(models.Model):
705 new_email_address = models.EmailField(_(u'new e-mail address'),
706 help_text=_(astakos_messages.EMAIL_CHANGE_NEW_ADDR_HELP))
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)