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