Merge remote-tracking branch 'origin/newstyles' into newstyles
[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
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     def __init__(self, *args, **kwargs):
100         super(AstakosUser, self).__init__(*args, **kwargs)
101         self.__has_signed_terms = self.has_signed_terms
102         if self.id:
103             self.__groupnames = [g.name for g in self.groups.all()]
104         else:
105             self.is_active = False
106     
107     @property
108     def realname(self):
109         return '%s %s' %(self.first_name, self.last_name)
110
111     @realname.setter
112     def realname(self, value):
113         parts = value.split(' ')
114         if len(parts) == 2:
115             self.first_name = parts[0]
116             self.last_name = parts[1]
117         else:
118             self.last_name = parts[0]
119
120     @property
121     def invitation(self):
122         try:
123             return Invitation.objects.get(username=self.email)
124         except Invitation.DoesNotExist:
125             return None
126
127     def save(self, update_timestamps=True, **kwargs):
128         if update_timestamps:
129             if not self.id:
130                 self.date_joined = datetime.now()
131             self.updated = datetime.now()
132         
133         # update date_signed_terms if necessary
134         if self.__has_signed_terms != self.has_signed_terms:
135             self.date_signed_terms = datetime.now()
136         
137         if not self.id:
138             # set username
139             while not self.username:
140                 username =  uuid.uuid4().hex[:30]
141                 try:
142                     AstakosUser.objects.get(username = username)
143                 except AstakosUser.DoesNotExist, e:
144                     self.username = username
145             if not self.provider:
146                 self.provider = 'local'
147         report_user_event(self)
148         self.validate_unique_email_isactive()
149         if self.is_active and self.activation_sent:
150             # reset the activation sent
151             self.activation_sent = None
152         super(AstakosUser, self).save(**kwargs)
153         
154         # set group if does not exist
155         groupname = 'shibboleth' if self.provider == 'shibboleth' else 'default'
156         if groupname not in self.__groupnames:
157             try:
158                 group = Group.objects.get(name = groupname)
159                 self.groups.add(group)
160             except Group.DoesNotExist, e:
161                 logger.exception(e)
162     
163     def renew_token(self):
164         md5 = hashlib.md5()
165         md5.update(self.username)
166         md5.update(self.realname.encode('ascii', 'ignore'))
167         md5.update(asctime())
168
169         self.auth_token = b64encode(md5.digest())
170         self.auth_token_created = datetime.now()
171         self.auth_token_expires = self.auth_token_created + \
172                                   timedelta(hours=AUTH_TOKEN_DURATION)
173         msg = 'Token renewed for %s' % self.email
174         logger._log(LOGGING_LEVEL, msg, [])
175
176     def __unicode__(self):
177         return self.username
178     
179     def conflicting_email(self):
180         q = AstakosUser.objects.exclude(username = self.username)
181         q = q.filter(email = self.email)
182         if q.count() != 0:
183             return True
184         return False
185     
186     def validate_unique_email_isactive(self):
187         """
188         Implements a unique_together constraint for email and is_active fields.
189         """
190         q = AstakosUser.objects.exclude(username = self.username)
191         q = q.filter(email = self.email)
192         q = q.filter(is_active = self.is_active)
193         if q.count() != 0:
194             raise ValidationError({'__all__':[_('Another account with the same email & is_active combination found.')]})
195     
196     def signed_terms(self):
197         term = get_latest_terms()
198         if not term:
199             return True
200         if not self.has_signed_terms:
201             return False
202         if not self.date_signed_terms:
203             return False
204         if self.date_signed_terms < term.date:
205             self.has_signed_terms = False
206             self.date_signed_terms = None
207             self.save()
208             return False
209         return True
210
211 class ApprovalTerms(models.Model):
212     """
213     Model for approval terms
214     """
215
216     date = models.DateTimeField('Issue date', db_index=True, default=datetime.now())
217     location = models.CharField('Terms location', max_length=255)
218
219 class Invitation(models.Model):
220     """
221     Model for registring invitations
222     """
223     inviter = models.ForeignKey(AstakosUser, related_name='invitations_sent',
224                                 null=True)
225     realname = models.CharField('Real name', max_length=255)
226     username = models.CharField('Unique ID', max_length=255, unique=True)
227     code = models.BigIntegerField('Invitation code', db_index=True)
228     is_consumed = models.BooleanField('Consumed?', default=False)
229     created = models.DateTimeField('Creation date', auto_now_add=True)
230     consumed = models.DateTimeField('Consumption date', null=True, blank=True)
231     
232     def __init__(self, *args, **kwargs):
233         super(Invitation, self).__init__(*args, **kwargs)
234         if not self.id:
235             self.code = _generate_invitation_code()
236     
237     def consume(self):
238         self.is_consumed = True
239         self.consumed = datetime.now()
240         self.save()
241
242     def __unicode__(self):
243         return '%s -> %s [%d]' % (self.inviter, self.username, self.code)
244
245 def report_user_event(user):
246     def should_send(user):
247         # report event incase of new user instance
248         # or if specific fields are modified
249         if not user.id:
250             return True
251         db_instance = AstakosUser.objects.get(id = user.id)
252         for f in BILLING_FIELDS:
253             if (db_instance.__getattribute__(f) != user.__getattribute__(f)):
254                 return True
255         return False
256
257     if QUEUE_CONNECTION and should_send(user):
258
259         from astakos.im.queue.userevent import UserEvent
260         from synnefo.lib.queue import exchange_connect, exchange_send, \
261                 exchange_close
262
263         eventType = 'create' if not user.id else 'modify'
264         body = UserEvent(QUEUE_CLIENT_ID, user, eventType, {}).format()
265         conn = exchange_connect(QUEUE_CONNECTION)
266         parts = urlparse(QUEUE_CONNECTION)
267         exchange = parts.path[1:]
268         routing_key = '%s.user' % exchange
269         exchange_send(conn, routing_key, body)
270         exchange_close(conn)
271
272 def _generate_invitation_code():
273     while True:
274         code = randint(1, 2L**63 - 1)
275         try:
276             Invitation.objects.get(code=code)
277             # An invitation with this code already exists, try again
278         except Invitation.DoesNotExist:
279             return code
280
281 def get_latest_terms():
282     try:
283         term = ApprovalTerms.objects.order_by('-id')[0]
284         return term
285     except IndexError:
286         pass
287     return None
288
289 class EmailChangeManager(models.Manager):
290     @transaction.commit_on_success
291     def change_email(self, activation_key):
292         """
293         Validate an activation key and change the corresponding
294         ``User`` if valid.
295
296         If the key is valid and has not expired, return the ``User``
297         after activating.
298
299         If the key is not valid or has expired, return ``None``.
300
301         If the key is valid but the ``User`` is already active,
302         return ``None``.
303
304         After successful email change the activation record is deleted.
305
306         Throws ValueError if there is already
307         """
308         try:
309             email_change = self.model.objects.get(activation_key=activation_key)
310             if email_change.activation_key_expired():
311                 email_change.delete()
312                 raise EmailChange.DoesNotExist
313             # is there an active user with this address?
314             try:
315                 AstakosUser.objects.get(email=email_change.new_email_address)
316             except AstakosUser.DoesNotExist:
317                 pass
318             else:
319                 raise ValueError(_('The new email address is reserved.'))
320             # update user
321             user = AstakosUser.objects.get(pk=email_change.user_id)
322             user.email = email_change.new_email_address
323             user.save()
324             email_change.delete()
325             return user
326         except EmailChange.DoesNotExist:
327             raise ValueError(_('Invalid activation key'))
328
329 class EmailChange(models.Model):
330     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.'))
331     user = models.ForeignKey(AstakosUser, unique=True, related_name='emailchange_user')
332     requested_at = models.DateTimeField(default=datetime.now())
333     activation_key = models.CharField(max_length=40, unique=True, db_index=True)
334
335     objects = EmailChangeManager()
336
337     def activation_key_expired(self):
338         expiration_date = timedelta(days=EMAILCHANGE_ACTIVATION_DAYS)
339         return self.requested_at + expiration_date < datetime.now()
340
341 class Service(models.Model):
342     name = models.CharField('Name', max_length=255, unique=True)
343     url = models.FilePathField()
344     icon = models.FilePathField(blank=True)
345     auth_token = models.CharField('Authentication Token', max_length=32,
346                                   null=True, blank=True)
347     auth_token_created = models.DateTimeField('Token creation date', null=True)
348     auth_token_expires = models.DateTimeField('Token expiration date', null=True)
349     
350     def save(self, **kwargs):
351         if not self.id:
352             self.renew_token()
353         self.full_clean()
354         super(Service, self).save(**kwargs)
355     
356     def renew_token(self):
357         md5 = hashlib.md5()
358         md5.update(self.name.encode('ascii', 'ignore'))
359         md5.update(self.url.encode('ascii', 'ignore'))
360         md5.update(asctime())
361
362         self.auth_token = b64encode(md5.digest())
363         self.auth_token_created = datetime.now()
364         self.auth_token_expires = self.auth_token_created + \
365                                   timedelta(hours=AUTH_TOKEN_DURATION)
366
367 class AdditionalMail(models.Model):
368     """
369     Model for registring invitations
370     """
371     owner = models.ForeignKey(AstakosUser)
372     email = models.EmailField(unique=True)
373
374 def create_astakos_user(u):
375     try:
376         AstakosUser.objects.get(user_ptr=u.pk)
377     except AstakosUser.DoesNotExist:
378         extended_user = AstakosUser(user_ptr_id=u.pk)
379         extended_user.__dict__.update(u.__dict__)
380         extended_user.renew_token()
381         extended_user.save()
382     except:
383         pass
384
385 def superuser_post_syncdb(sender, **kwargs):
386     # if there was created a superuser
387     # associate it with an AstakosUser
388     admins = User.objects.filter(is_superuser=True)
389     for u in admins:
390         create_astakos_user(u)
391
392 post_syncdb.connect(superuser_post_syncdb)
393
394 def superuser_post_save(sender, instance, **kwargs):
395     if instance.is_superuser:
396         create_astakos_user(instance)
397
398 post_save.connect(superuser_post_save, sender=User)