36008105c9b262a70924c4d7765f753a03c96e8e
[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
43 from random import randint
44
45 from django.db import models, IntegrityError
46 from django.contrib.auth.models import User, UserManager, Group
47 from django.utils.translation import ugettext as _
48 from django.core.exceptions import ValidationError
49 from django.template.loader import render_to_string
50 from django.core.mail import send_mail
51 from django.db import transaction
52 from django.db.models.signals import post_save, post_syncdb
53
54 from astakos.im.settings import DEFAULT_USER_LEVEL, INVITATIONS_PER_LEVEL, \
55     AUTH_TOKEN_DURATION, BILLING_FIELDS, QUEUE_CONNECTION, SITENAME, \
56     EMAILCHANGE_ACTIVATION_DAYS, LOGGING_LEVEL
57
58 QUEUE_CLIENT_ID = 3 # Astakos.
59
60 logger = logging.getLogger(__name__)
61
62 class AstakosUser(User):
63     """
64     Extends ``django.contrib.auth.models.User`` by defining additional fields.
65     """
66     # Use UserManager to get the create_user method, etc.
67     objects = UserManager()
68
69     affiliation = models.CharField('Affiliation', max_length=255, blank=True)
70     provider = models.CharField('Provider', max_length=255, blank=True)
71
72     #for invitations
73     user_level = DEFAULT_USER_LEVEL
74     level = models.IntegerField('Inviter level', default=user_level)
75     invitations = models.IntegerField('Invitations left', default=INVITATIONS_PER_LEVEL.get(user_level, 0))
76
77     auth_token = models.CharField('Authentication Token', max_length=32,
78                                   null=True, blank=True)
79     auth_token_created = models.DateTimeField('Token creation date', null=True)
80     auth_token_expires = models.DateTimeField('Token expiration date', null=True)
81
82     updated = models.DateTimeField('Update date')
83     is_verified = models.BooleanField('Is verified?', default=False)
84
85     # ex. screen_name for twitter, eppn for shibboleth
86     third_party_identifier = models.CharField('Third-party identifier', max_length=255, null=True, blank=True)
87
88     email_verified = models.BooleanField('Email verified?', default=False)
89
90     has_credits = models.BooleanField('Has credits?', default=False)
91     has_signed_terms = models.BooleanField('Agree with the terms?', default=False)
92     date_signed_terms = models.DateTimeField('Signed terms date', null=True, blank=True)
93     
94     activation_sent = models.DateTimeField('Activation sent data', null=True, blank=True)
95     
96     __has_signed_terms = False
97     __groupnames = []
98     
99     class Meta:
100         unique_together = ("provider", "third_party_identifier")
101     
102     def __init__(self, *args, **kwargs):
103         super(AstakosUser, self).__init__(*args, **kwargs)
104         self.__has_signed_terms = self.has_signed_terms
105         if self.id:
106             self.__groupnames = [g.name for g in self.groups.all()]
107         else:
108             self.is_active = False
109     
110     @property
111     def realname(self):
112         return '%s %s' %(self.first_name, self.last_name)
113
114     @realname.setter
115     def realname(self, value):
116         parts = value.split(' ')
117         if len(parts) == 2:
118             self.first_name = parts[0]
119             self.last_name = parts[1]
120         else:
121             self.last_name = parts[0]
122
123     @property
124     def invitation(self):
125         try:
126             return Invitation.objects.get(username=self.email)
127         except Invitation.DoesNotExist:
128             return None
129
130     def save(self, update_timestamps=True, **kwargs):
131         if update_timestamps:
132             if not self.id:
133                 self.date_joined = datetime.now()
134             self.updated = datetime.now()
135         
136         # update date_signed_terms if necessary
137         if self.__has_signed_terms != self.has_signed_terms:
138             self.date_signed_terms = datetime.now()
139         
140         if not self.id:
141             # set username
142             while not self.username:
143                 username =  uuid.uuid4().hex[:30]
144                 try:
145                     AstakosUser.objects.get(username = username)
146                 except AstakosUser.DoesNotExist, e:
147                     self.username = username
148             if not self.provider:
149                 self.provider = 'local'
150         report_user_event(self)
151         self.validate_unique_email_isactive()
152         if self.is_active and self.activation_sent:
153             # reset the activation sent
154             self.activation_sent = None
155         super(AstakosUser, self).save(**kwargs)
156         
157         # set group if does not exist
158         groupname = 'shibboleth' if self.provider == 'shibboleth' else 'default'
159         if groupname not in self.__groupnames:
160             try:
161                 group = Group.objects.get(name = groupname)
162                 self.groups.add(group)
163             except Group.DoesNotExist, e:
164                 logger.exception(e)
165     
166     def renew_token(self):
167         md5 = hashlib.md5()
168         md5.update(self.username)
169         md5.update(self.realname.encode('ascii', 'ignore'))
170         md5.update(asctime())
171
172         self.auth_token = b64encode(md5.digest())
173         self.auth_token_created = datetime.now()
174         self.auth_token_expires = self.auth_token_created + \
175                                   timedelta(hours=AUTH_TOKEN_DURATION)
176         msg = 'Token renewed for %s' % self.email
177         logger._log(LOGGING_LEVEL, msg, [])
178
179     def __unicode__(self):
180         return self.username
181     
182     def conflicting_email(self):
183         q = AstakosUser.objects.exclude(username = self.username)
184         q = q.filter(email = self.email)
185         if q.count() != 0:
186             return True
187         return False
188     
189     def validate_unique_email_isactive(self):
190         """
191         Implements a unique_together constraint for email and is_active fields.
192         """
193         q = AstakosUser.objects.exclude(username = self.username)
194         q = q.filter(email = self.email)
195         q = q.filter(is_active = self.is_active)
196         if q.count() != 0:
197             raise ValidationError({'__all__':[_('Another account with the same email & is_active combination found.')]})
198     
199     def signed_terms(self):
200         term = get_latest_terms()
201         if not term:
202             return True
203         if not self.has_signed_terms:
204             return False
205         if not self.date_signed_terms:
206             return False
207         if self.date_signed_terms < term.date:
208             self.has_signed_terms = False
209             self.date_signed_terms = None
210             self.save()
211             return False
212         return True
213
214 class ApprovalTerms(models.Model):
215     """
216     Model for approval terms
217     """
218
219     date = models.DateTimeField('Issue date', db_index=True, default=datetime.now())
220     location = models.CharField('Terms location', max_length=255)
221
222 class Invitation(models.Model):
223     """
224     Model for registring invitations
225     """
226     inviter = models.ForeignKey(AstakosUser, related_name='invitations_sent',
227                                 null=True)
228     realname = models.CharField('Real name', max_length=255)
229     username = models.CharField('Unique ID', max_length=255, unique=True)
230     code = models.BigIntegerField('Invitation code', db_index=True)
231     is_consumed = models.BooleanField('Consumed?', default=False)
232     created = models.DateTimeField('Creation date', auto_now_add=True)
233     consumed = models.DateTimeField('Consumption date', null=True, blank=True)
234     
235     def __init__(self, *args, **kwargs):
236         super(Invitation, self).__init__(*args, **kwargs)
237         if not self.id:
238             self.code = _generate_invitation_code()
239     
240     def consume(self):
241         self.is_consumed = True
242         self.consumed = datetime.now()
243         self.save()
244
245     def __unicode__(self):
246         return '%s -> %s [%d]' % (self.inviter, self.username, self.code)
247
248 def report_user_event(user):
249     def should_send(user):
250         # report event incase of new user instance
251         # or if specific fields are modified
252         if not user.id:
253             return True
254         db_instance = AstakosUser.objects.get(id = user.id)
255         for f in BILLING_FIELDS:
256             if (db_instance.__getattribute__(f) != user.__getattribute__(f)):
257                 return True
258         return False
259
260     if QUEUE_CONNECTION and should_send(user):
261
262         from astakos.im.queue.userevent import UserEvent
263         from synnefo.lib.queue import exchange_connect, exchange_send, \
264                 exchange_close
265
266         eventType = 'create' if not user.id else 'modify'
267         body = UserEvent(QUEUE_CLIENT_ID, user, eventType, {}).format()
268         conn = exchange_connect(QUEUE_CONNECTION)
269         parts = urlparse(QUEUE_CONNECTION)
270         exchange = parts.path[1:]
271         routing_key = '%s.user' % exchange
272         exchange_send(conn, routing_key, body)
273         exchange_close(conn)
274
275 def _generate_invitation_code():
276     while True:
277         code = randint(1, 2L**63 - 1)
278         try:
279             Invitation.objects.get(code=code)
280             # An invitation with this code already exists, try again
281         except Invitation.DoesNotExist:
282             return code
283
284 def get_latest_terms():
285     try:
286         term = ApprovalTerms.objects.order_by('-id')[0]
287         return term
288     except IndexError:
289         pass
290     return None
291
292 class EmailChangeManager(models.Manager):
293     @transaction.commit_on_success
294     def change_email(self, activation_key):
295         """
296         Validate an activation key and change the corresponding
297         ``User`` if valid.
298
299         If the key is valid and has not expired, return the ``User``
300         after activating.
301
302         If the key is not valid or has expired, return ``None``.
303
304         If the key is valid but the ``User`` is already active,
305         return ``None``.
306
307         After successful email change the activation record is deleted.
308
309         Throws ValueError if there is already
310         """
311         try:
312             email_change = self.model.objects.get(activation_key=activation_key)
313             if email_change.activation_key_expired():
314                 email_change.delete()
315                 raise EmailChange.DoesNotExist
316             # is there an active user with this address?
317             try:
318                 AstakosUser.objects.get(email=email_change.new_email_address)
319             except AstakosUser.DoesNotExist:
320                 pass
321             else:
322                 raise ValueError(_('The new email address is reserved.'))
323             # update user
324             user = AstakosUser.objects.get(pk=email_change.user_id)
325             user.email = email_change.new_email_address
326             user.save()
327             email_change.delete()
328             return user
329         except EmailChange.DoesNotExist:
330             raise ValueError(_('Invalid activation key'))
331
332 class EmailChange(models.Model):
333     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.'))
334     user = models.ForeignKey(AstakosUser, unique=True, related_name='emailchange_user')
335     requested_at = models.DateTimeField(default=datetime.now())
336     activation_key = models.CharField(max_length=40, unique=True, db_index=True)
337
338     objects = EmailChangeManager()
339
340     def activation_key_expired(self):
341         expiration_date = timedelta(days=EMAILCHANGE_ACTIVATION_DAYS)
342         return self.requested_at + expiration_date < datetime.now()
343
344 class Service(models.Model):
345     name = models.CharField('Name', max_length=255, unique=True)
346     url = models.FilePathField()
347     icon = models.FilePathField(blank=True)
348     auth_token = models.CharField('Authentication Token', max_length=32,
349                                   null=True, blank=True)
350     auth_token_created = models.DateTimeField('Token creation date', null=True)
351     auth_token_expires = models.DateTimeField('Token expiration date', null=True)
352     
353     def save(self, **kwargs):
354         if not self.id:
355             self.renew_token()
356         self.full_clean()
357         super(Service, self).save(**kwargs)
358     
359     def renew_token(self):
360         md5 = hashlib.md5()
361         md5.update(self.name.encode('ascii', 'ignore'))
362         md5.update(self.url.encode('ascii', 'ignore'))
363         md5.update(asctime())
364
365         self.auth_token = b64encode(md5.digest())
366         self.auth_token_created = datetime.now()
367         self.auth_token_expires = self.auth_token_created + \
368                                   timedelta(hours=AUTH_TOKEN_DURATION)
369
370 class AdditionalMail(models.Model):
371     """
372     Model for registring invitations
373     """
374     owner = models.ForeignKey(AstakosUser)
375     email = models.EmailField()
376
377 class PendingThirdPartyUser(models.Model):
378     """
379     Model for registring successful third party user authentications
380     """
381     third_party_identifier = models.CharField('Third-party identifier', max_length=255, null=True, blank=True)
382     provider = models.CharField('Provider', max_length=255, blank=True)
383     email = models.EmailField(_('e-mail address'), blank=True)
384     first_name = models.CharField(_('first name'), max_length=30, blank=True)
385     last_name = models.CharField(_('last name'), max_length=30, blank=True)
386     affiliation = models.CharField('Affiliation', max_length=255, blank=True)
387     username = models.CharField(_('username'), max_length=30, unique=True, help_text=_("Required. 30 characters or fewer. Letters, numbers and @/./+/-/_ characters"))
388
389     @property
390     def realname(self):
391         return '%s %s' %(self.first_name, self.last_name)
392
393     @realname.setter
394     def realname(self, value):
395         parts = value.split(' ')
396         if len(parts) == 2:
397             self.first_name = parts[0]
398             self.last_name = parts[1]
399         else:
400             self.last_name = parts[0]
401     
402     def save(self, **kwargs):
403         if not self.id:
404             # set username
405             while not self.username:
406                 username =  uuid.uuid4().hex[:30]
407                 try:
408                     AstakosUser.objects.get(username = username)
409                 except AstakosUser.DoesNotExist, e:
410                     self.username = username
411         super(PendingThirdPartyUser, self).save(**kwargs)
412
413 def create_astakos_user(u):
414     try:
415         AstakosUser.objects.get(user_ptr=u.pk)
416     except AstakosUser.DoesNotExist:
417         extended_user = AstakosUser(user_ptr_id=u.pk)
418         extended_user.__dict__.update(u.__dict__)
419         extended_user.renew_token()
420         extended_user.save()
421     except:
422         pass
423
424 def superuser_post_syncdb(sender, **kwargs):
425     # if there was created a superuser
426     # associate it with an AstakosUser
427     admins = User.objects.filter(is_superuser=True)
428     for u in admins:
429         create_astakos_user(u)
430
431 post_syncdb.connect(superuser_post_syncdb)
432
433 def superuser_post_save(sender, instance, **kwargs):
434     if instance.is_superuser:
435         create_astakos_user(instance)
436
437 post_save.connect(superuser_post_save, sender=User)