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