fix migration 0017: set date_joined when setting default group
[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 import json
38
39 from time import asctime
40 from datetime import datetime, timedelta
41 from base64 import b64encode
42 from urlparse import urlparse, urlunparse
43 from random import randint
44 from collections import defaultdict
45 from south.signals import post_migrate
46
47 from django.db import models, IntegrityError
48 from django.contrib.auth.models import User, UserManager, Group
49 from django.utils.translation import ugettext as _
50 from django.core.exceptions import ValidationError
51 from django.template.loader import render_to_string
52 from django.core.mail import send_mail
53 from django.db import transaction
54 from django.db.models.signals import post_save, post_syncdb
55 from django.db.models import Q, Count
56
57 from astakos.im.settings import DEFAULT_USER_LEVEL, INVITATIONS_PER_LEVEL, \
58     AUTH_TOKEN_DURATION, BILLING_FIELDS, QUEUE_CONNECTION, SITENAME, \
59     EMAILCHANGE_ACTIVATION_DAYS, LOGGING_LEVEL
60
61 QUEUE_CLIENT_ID = 3 # Astakos.
62
63 logger = logging.getLogger(__name__)
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('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 class ResourceMetadata(models.Model):
95     key = models.CharField('Name', max_length=255, unique=True, db_index=True)
96     value = models.CharField('Value', max_length=255)
97
98 class Resource(models.Model):
99     name = models.CharField('Name', max_length=255, unique=True, db_index=True)
100     meta = models.ManyToManyField(ResourceMetadata)
101     service = models.ForeignKey(Service)
102     
103     def __str__(self):
104         return '%s : %s' % (self.service, self.name)
105
106 class GroupKind(models.Model):
107     name = models.CharField('Name', max_length=255, unique=True, db_index=True)
108     
109     def __str__(self):
110         return self.name
111
112 class AstakosGroup(Group):
113     kind = models.ForeignKey(GroupKind)
114     desc = models.TextField('Description', null=True)
115     policy = models.ManyToManyField(Resource, null=True, blank=True, through='AstakosGroupQuota')
116     creation_date = models.DateTimeField('Creation date', default=datetime.now())
117     issue_date = models.DateTimeField('Issue date', null=True)
118     expiration_date = models.DateTimeField('Expiration date', null=True)
119     moderation_enabled = models.BooleanField('Moderated membership?', default=True)
120     approval_date = models.DateTimeField('Activation date', null=True, blank=True)
121     estimated_participants = models.PositiveIntegerField('Estimated #participants', null=True)
122     
123     @property
124     def is_disabled(self):
125         if not self.approval_date:
126             return True
127         return False
128     
129     @property
130     def is_enabled(self):
131         if self.is_disabled:
132             return False
133         if not self.issue_date:
134             return False
135         if not self.expiration_date:
136             return True
137         now = datetime.now()
138         if self.issue_date > now:
139             return False
140         if now >= self.expiration_date:
141             return False
142         return True
143     
144     @property
145     def participants(self):
146         return len(self.approved_members)
147     
148     def enable(self):
149         self.approval_date = datetime.now()
150         self.save()
151     
152     def disable(self):
153         self.approval_date = None
154         self.save()
155     
156     def approve_member(self, person):
157         try:
158             self.membership_set.create(person=person, date_joined=datetime.now())
159         except IntegrityError:
160             m = self.membership_set.get(person=person)
161             m.date_joined = datetime.now()
162             m.save()
163     
164     def disapprove_member(self, person):
165         self.membership_set.remove(person=person)
166     
167     @property
168     def members(self):
169         return map(lambda m:m.person, self.membership_set.all())
170     
171     @property
172     def approved_members(self):
173         f = filter(lambda m:m.is_approved, self.membership_set.all())
174         return map(lambda m:m.person, f)
175     
176     @property
177     def quota(self):
178         d = {}
179         for q in  self.astakosgroupquota_set.all():
180             d[q.resource.name] = q.limit
181         return d
182     
183     @property
184     def has_undefined_policies(self):
185         # TODO: can avoid query?
186         return Resource.objects.filter(~Q(astakosgroup=self)).exists()
187
188 class AstakosUser(User):
189     """
190     Extends ``django.contrib.auth.models.User`` by defining additional fields.
191     """
192     # Use UserManager to get the create_user method, etc.
193     objects = UserManager()
194
195     affiliation = models.CharField('Affiliation', max_length=255, blank=True)
196     provider = models.CharField('Provider', max_length=255, blank=True)
197
198     #for invitations
199     user_level = DEFAULT_USER_LEVEL
200     level = models.IntegerField('Inviter level', default=user_level)
201     invitations = models.IntegerField('Invitations left', default=INVITATIONS_PER_LEVEL.get(user_level, 0))
202
203     auth_token = models.CharField('Authentication Token', max_length=32,
204                                   null=True, blank=True)
205     auth_token_created = models.DateTimeField('Token creation date', null=True)
206     auth_token_expires = models.DateTimeField('Token expiration date', null=True)
207
208     updated = models.DateTimeField('Update date')
209     is_verified = models.BooleanField('Is verified?', default=False)
210
211     # ex. screen_name for twitter, eppn for shibboleth
212     third_party_identifier = models.CharField('Third-party identifier', max_length=255, null=True, blank=True)
213
214     email_verified = models.BooleanField('Email verified?', default=False)
215
216     has_credits = models.BooleanField('Has credits?', default=False)
217     has_signed_terms = models.BooleanField('Agree with the terms?', default=False)
218     date_signed_terms = models.DateTimeField('Signed terms date', null=True, blank=True)
219     
220     activation_sent = models.DateTimeField('Activation sent data', null=True, blank=True)
221     
222     policy = models.ManyToManyField(Resource, null=True, through='AstakosUserQuota')
223     
224     astakos_groups = models.ManyToManyField(AstakosGroup, verbose_name=_('agroups'), blank=True,
225         help_text=_("In addition to the permissions manually assigned, this user will also get all permissions granted to each group he/she is in."),
226         through='Membership')
227     
228     __has_signed_terms = False
229     __groupnames = []
230     
231     owner = models.ManyToManyField(AstakosGroup, related_name='owner', null=True)
232     
233     class Meta:
234         unique_together = ("provider", "third_party_identifier")
235     
236     def __init__(self, *args, **kwargs):
237         super(AstakosUser, self).__init__(*args, **kwargs)
238         self.__has_signed_terms = self.has_signed_terms
239         if self.id:
240             self.__groupnames = [g.name for g in self.astakos_groups.all()]
241         else:
242             self.is_active = False
243     
244     @property
245     def realname(self):
246         return '%s %s' %(self.first_name, self.last_name)
247
248     @realname.setter
249     def realname(self, value):
250         parts = value.split(' ')
251         if len(parts) == 2:
252             self.first_name = parts[0]
253             self.last_name = parts[1]
254         else:
255             self.last_name = parts[0]
256
257     @property
258     def invitation(self):
259         try:
260             return Invitation.objects.get(username=self.email)
261         except Invitation.DoesNotExist:
262             return None
263     
264     @property
265     def quota(self):
266         d = defaultdict(int)
267         for q in  self.astakosuserquota_set.all():
268             d[q.resource.name] += q.limit
269         for g in self.astakos_groups.all():
270             if not g.is_enabled:
271                 continue
272             for r, limit in g.quota.iteritems():
273                 d[r] += limit
274         # TODO set default for remaining
275         return d
276         
277     def save(self, update_timestamps=True, **kwargs):
278         if update_timestamps:
279             if not self.id:
280                 self.date_joined = datetime.now()
281             self.updated = datetime.now()
282         
283         # update date_signed_terms if necessary
284         if self.__has_signed_terms != self.has_signed_terms:
285             self.date_signed_terms = datetime.now()
286         
287         if not self.id:
288             # set username
289             while not self.username:
290                 username =  uuid.uuid4().hex[:30]
291                 try:
292                     AstakosUser.objects.get(username = username)
293                 except AstakosUser.DoesNotExist, e:
294                     self.username = username
295             if not self.provider:
296                 self.provider = 'local'
297         report_user_event(self)
298         self.validate_unique_email_isactive()
299         if self.is_active and self.activation_sent:
300             # reset the activation sent
301             self.activation_sent = None
302         
303         super(AstakosUser, self).save(**kwargs)
304         
305         # set group if does not exist
306         groupname = 'shibboleth' if self.provider == 'shibboleth' else 'default'
307         if groupname not in self.__groupnames:
308             try:
309                 group = AstakosGroup.objects.get(name = groupname)
310                 Membership(group=group, person=self, date_joined=datetime.now()).save()
311             except AstakosGroup.DoesNotExist, e:
312                 logger.exception(e)
313     
314     def renew_token(self):
315         md5 = hashlib.md5()
316         md5.update(self.username)
317         md5.update(self.realname.encode('ascii', 'ignore'))
318         md5.update(asctime())
319
320         self.auth_token = b64encode(md5.digest())
321         self.auth_token_created = datetime.now()
322         self.auth_token_expires = self.auth_token_created + \
323                                   timedelta(hours=AUTH_TOKEN_DURATION)
324         msg = 'Token renewed for %s' % self.email
325         logger._log(LOGGING_LEVEL, msg, [])
326
327     def __unicode__(self):
328         return self.username
329     
330     def conflicting_email(self):
331         q = AstakosUser.objects.exclude(username = self.username)
332         q = q.filter(email = self.email)
333         if q.count() != 0:
334             return True
335         return False
336     
337     def validate_unique_email_isactive(self):
338         """
339         Implements a unique_together constraint for email and is_active fields.
340         """
341         q = AstakosUser.objects.exclude(username = self.username)
342         q = q.filter(email = self.email)
343         q = q.filter(is_active = self.is_active)
344         if q.count() != 0:
345             raise ValidationError({'__all__':[_('Another account with the same email & is_active combination found.')]})
346     
347     def signed_terms(self):
348         term = get_latest_terms()
349         if not term:
350             return True
351         if not self.has_signed_terms:
352             return False
353         if not self.date_signed_terms:
354             return False
355         if self.date_signed_terms < term.date:
356             self.has_signed_terms = False
357             self.date_signed_terms = None
358             self.save()
359             return False
360         return True
361
362 class Membership(models.Model):
363     person = models.ForeignKey(AstakosUser)
364     group = models.ForeignKey(AstakosGroup)
365     date_requested = models.DateField(default=datetime.now(), blank=True)
366     date_joined = models.DateField(null=True, db_index=True, blank=True)
367     
368     class Meta:
369         unique_together = ("person", "group")
370     
371     def save(self, *args, **kwargs):
372         if not self.id:
373             if not self.group.moderation_enabled:
374                 self.date_joined = datetime.now()
375         super(Membership, self).save(*args, **kwargs)
376     
377     @property
378     def is_approved(self):
379         if self.date_joined:
380             return True
381         return False
382     
383     def approve(self):
384         self.date_joined = datetime.now()
385         self.save()
386         
387     def disapprove(self):
388         self.delete()
389
390 class AstakosGroupQuota(models.Model):
391     limit = models.PositiveIntegerField('Limit')
392     resource = models.ForeignKey(Resource)
393     group = models.ForeignKey(AstakosGroup, blank=True)
394     
395     class Meta:
396         unique_together = ("resource", "group")
397
398 class AstakosUserQuota(models.Model):
399     limit = models.PositiveIntegerField('Limit')
400     resource = models.ForeignKey(Resource)
401     user = models.ForeignKey(AstakosUser)
402     
403     class Meta:
404         unique_together = ("resource", "user")
405
406 class ApprovalTerms(models.Model):
407     """
408     Model for approval terms
409     """
410
411     date = models.DateTimeField('Issue date', db_index=True, default=datetime.now())
412     location = models.CharField('Terms location', max_length=255)
413
414 class Invitation(models.Model):
415     """
416     Model for registring invitations
417     """
418     inviter = models.ForeignKey(AstakosUser, related_name='invitations_sent',
419                                 null=True)
420     realname = models.CharField('Real name', max_length=255)
421     username = models.CharField('Unique ID', max_length=255, unique=True)
422     code = models.BigIntegerField('Invitation code', db_index=True)
423     is_consumed = models.BooleanField('Consumed?', default=False)
424     created = models.DateTimeField('Creation date', auto_now_add=True)
425     consumed = models.DateTimeField('Consumption date', null=True, blank=True)
426     
427     def __init__(self, *args, **kwargs):
428         super(Invitation, self).__init__(*args, **kwargs)
429         if not self.id:
430             self.code = _generate_invitation_code()
431     
432     def consume(self):
433         self.is_consumed = True
434         self.consumed = datetime.now()
435         self.save()
436
437     def __unicode__(self):
438         return '%s -> %s [%d]' % (self.inviter, self.username, self.code)
439
440 def report_user_event(user):
441     def should_send(user):
442         # report event incase of new user instance
443         # or if specific fields are modified
444         if not user.id:
445             return True
446         try:
447             db_instance = AstakosUser.objects.get(id = user.id)
448         except AstakosUser.DoesNotExist:
449             return True
450         for f in BILLING_FIELDS:
451             if (db_instance.__getattribute__(f) != user.__getattribute__(f)):
452                 return True
453         return False
454
455     if QUEUE_CONNECTION and should_send(user):
456
457         from astakos.im.queue.userevent import UserEvent
458         from synnefo.lib.queue import exchange_connect, exchange_send, \
459                 exchange_close
460
461         eventType = 'create' if not user.id else 'modify'
462         body = UserEvent(QUEUE_CLIENT_ID, user, eventType, {}).format()
463         conn = exchange_connect(QUEUE_CONNECTION)
464         parts = urlparse(QUEUE_CONNECTION)
465         exchange = parts.path[1:]
466         routing_key = '%s.user' % exchange
467         exchange_send(conn, routing_key, body)
468         exchange_close(conn)
469
470 def _generate_invitation_code():
471     while True:
472         code = randint(1, 2L**63 - 1)
473         try:
474             Invitation.objects.get(code=code)
475             # An invitation with this code already exists, try again
476         except Invitation.DoesNotExist:
477             return code
478
479 def get_latest_terms():
480     try:
481         term = ApprovalTerms.objects.order_by('-id')[0]
482         return term
483     except IndexError:
484         pass
485     return None
486
487 class EmailChangeManager(models.Manager):
488     @transaction.commit_on_success
489     def change_email(self, activation_key):
490         """
491         Validate an activation key and change the corresponding
492         ``User`` if valid.
493
494         If the key is valid and has not expired, return the ``User``
495         after activating.
496
497         If the key is not valid or has expired, return ``None``.
498
499         If the key is valid but the ``User`` is already active,
500         return ``None``.
501
502         After successful email change the activation record is deleted.
503
504         Throws ValueError if there is already
505         """
506         try:
507             email_change = self.model.objects.get(activation_key=activation_key)
508             if email_change.activation_key_expired():
509                 email_change.delete()
510                 raise EmailChange.DoesNotExist
511             # is there an active user with this address?
512             try:
513                 AstakosUser.objects.get(email=email_change.new_email_address)
514             except AstakosUser.DoesNotExist:
515                 pass
516             else:
517                 raise ValueError(_('The new email address is reserved.'))
518             # update user
519             user = AstakosUser.objects.get(pk=email_change.user_id)
520             user.email = email_change.new_email_address
521             user.save()
522             email_change.delete()
523             return user
524         except EmailChange.DoesNotExist:
525             raise ValueError(_('Invalid activation key'))
526
527 class EmailChange(models.Model):
528     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.'))
529     user = models.ForeignKey(AstakosUser, unique=True, related_name='emailchange_user')
530     requested_at = models.DateTimeField(default=datetime.now())
531     activation_key = models.CharField(max_length=40, unique=True, db_index=True)
532
533     objects = EmailChangeManager()
534
535     def activation_key_expired(self):
536         expiration_date = timedelta(days=EMAILCHANGE_ACTIVATION_DAYS)
537         return self.requested_at + expiration_date < datetime.now()
538
539 class AdditionalMail(models.Model):
540     """
541     Model for registring invitations
542     """
543     owner = models.ForeignKey(AstakosUser)
544     email = models.EmailField()
545
546 def create_astakos_user(u):
547     try:
548         AstakosUser.objects.get(user_ptr=u.pk)
549     except AstakosUser.DoesNotExist:
550         extended_user = AstakosUser(user_ptr_id=u.pk)
551         extended_user.__dict__.update(u.__dict__)
552         extended_user.renew_token()
553         extended_user.save()
554     except:
555         pass
556
557 def superuser_post_syncdb(sender, **kwargs):
558     # if there was created a superuser
559     # associate it with an AstakosUser
560     admins = User.objects.filter(is_superuser=True)
561     for u in admins:
562         create_astakos_user(u)
563
564 post_syncdb.connect(superuser_post_syncdb)
565
566 def superuser_post_save(sender, instance, **kwargs):
567     if instance.is_superuser:
568         create_astakos_user(instance)
569
570 post_save.connect(superuser_post_save, sender=User)