clear date_signed_terms field if there are updated approval terms for user to sign.
[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
53 from astakos.im.settings import DEFAULT_USER_LEVEL, INVITATIONS_PER_LEVEL, \
54 AUTH_TOKEN_DURATION, BILLING_FIELDS, QUEUE_CONNECTION, SITENAME, \
55 EMAILCHANGE_ACTIVATION_DAYS
56
57 QUEUE_CLIENT_ID = 3 # Astakos.
58
59 logger = logging.getLogger(__name__)
60
61 class AstakosUser(User):
62     """
63     Extends ``django.contrib.auth.models.User`` by defining additional fields.
64     """
65     # Use UserManager to get the create_user method, etc.
66     objects = UserManager()
67
68     affiliation = models.CharField('Affiliation', max_length=255, blank=True)
69     provider = models.CharField('Provider', max_length=255, blank=True)
70
71     #for invitations
72     user_level = DEFAULT_USER_LEVEL
73     level = models.IntegerField('Inviter level', default=user_level)
74     invitations = models.IntegerField('Invitations left', default=INVITATIONS_PER_LEVEL.get(user_level, 0))
75
76     auth_token = models.CharField('Authentication Token', max_length=32,
77                                   null=True, blank=True)
78     auth_token_created = models.DateTimeField('Token creation date', null=True)
79     auth_token_expires = models.DateTimeField('Token expiration date', null=True)
80
81     updated = models.DateTimeField('Update date')
82     is_verified = models.BooleanField('Is verified?', default=False)
83
84     # ex. screen_name for twitter, eppn for shibboleth
85     third_party_identifier = models.CharField('Third-party identifier', max_length=255, null=True, blank=True)
86
87     email_verified = models.BooleanField('Email verified?', default=False)
88
89     has_credits = models.BooleanField('Has credits?', default=False)
90     has_signed_terms = models.BooleanField('Agree with the terms?', default=False)
91     date_signed_terms = models.DateTimeField('Signed terms date', null=True, blank=True)
92     
93     __has_signed_terms = False
94     __groupnames = []
95     
96     def __init__(self, *args, **kwargs):
97         super(AstakosUser, self).__init__(*args, **kwargs)
98         self.__has_signed_terms = self.has_signed_terms
99         if self.id:
100             self.__groupnames = [g.name for g in self.groups.all()]
101         else:
102             self.is_active = False
103     
104     @property
105     def realname(self):
106         return '%s %s' %(self.first_name, self.last_name)
107
108     @realname.setter
109     def realname(self, value):
110         parts = value.split(' ')
111         if len(parts) == 2:
112             self.first_name = parts[0]
113             self.last_name = parts[1]
114         else:
115             self.last_name = parts[0]
116
117     @property
118     def invitation(self):
119         try:
120             return Invitation.objects.get(username=self.email)
121         except Invitation.DoesNotExist:
122             return None
123
124     def save(self, update_timestamps=True, **kwargs):
125         if update_timestamps:
126             if not self.id:
127                 self.date_joined = datetime.now()
128             self.updated = datetime.now()
129         
130         # update date_signed_terms if necessary
131         if self.__has_signed_terms != self.has_signed_terms:
132             self.date_signed_terms = datetime.now()
133         
134         if not self.id:
135             # set username
136             while not self.username:
137                 username =  uuid.uuid4().hex[:30]
138                 try:
139                     AstakosUser.objects.get(username = username)
140                 except AstakosUser.DoesNotExist, e:
141                     self.username = username
142             if not self.provider:
143                 self.provider = 'local'
144         report_user_event(self)
145         self.validate_unique_email_isactive()
146         super(AstakosUser, self).save(**kwargs)
147         
148         # set group if does not exist
149         groupname = 'shibboleth' if self.provider == 'shibboleth' else 'default'
150         if groupname not in self.__groupnames:
151             try:
152                 group = Group.objects.get(name = groupname)
153                 self.groups.add(group)
154             except Group.DoesNotExist, e:
155                 logger.exception(e)
156     
157     def renew_token(self):
158         md5 = hashlib.md5()
159         md5.update(self.username)
160         md5.update(self.realname.encode('ascii', 'ignore'))
161         md5.update(asctime())
162
163         self.auth_token = b64encode(md5.digest())
164         self.auth_token_created = datetime.now()
165         self.auth_token_expires = self.auth_token_created + \
166                                   timedelta(hours=AUTH_TOKEN_DURATION)
167
168     def __unicode__(self):
169         return self.username
170     
171     def conflicting_email(self):
172         q = AstakosUser.objects.exclude(username = self.username)
173         q = q.filter(email = self.email)
174         if q.count() != 0:
175             return True
176         return False
177     
178     def validate_unique_email_isactive(self):
179         """
180         Implements a unique_together constraint for email and is_active fields.
181         """
182         q = AstakosUser.objects.exclude(username = self.username)
183         q = q.filter(email = self.email)
184         q = q.filter(is_active = self.is_active)
185         if q.count() != 0:
186             raise ValidationError({'__all__':[_('Another account with the same email & is_active combination found.')]})
187     
188     def signed_terms(self):
189         term = get_latest_terms()
190         if not term:
191             return True
192         if not self.has_signed_terms:
193             return False
194         if not self.date_signed_terms:
195             return False
196         if self.date_signed_terms < term.date:
197             self.has_signed_terms = False
198             self.date_signed_terms = None
199             self.save()
200             return False
201         return True
202
203 class ApprovalTerms(models.Model):
204     """
205     Model for approval terms
206     """
207
208     date = models.DateTimeField('Issue date', db_index=True, default=datetime.now())
209     location = models.CharField('Terms location', max_length=255)
210
211 class Invitation(models.Model):
212     """
213     Model for registring invitations
214     """
215     inviter = models.ForeignKey(AstakosUser, related_name='invitations_sent',
216                                 null=True)
217     realname = models.CharField('Real name', max_length=255)
218     username = models.CharField('Unique ID', max_length=255, unique=True)
219     code = models.BigIntegerField('Invitation code', db_index=True)
220     is_consumed = models.BooleanField('Consumed?', default=False)
221     created = models.DateTimeField('Creation date', auto_now_add=True)
222     consumed = models.DateTimeField('Consumption date', null=True, blank=True)
223     
224     def __init__(self, *args, **kwargs):
225         super(Invitation, self).__init__(*args, **kwargs)
226         if not self.id:
227             self.code = _generate_invitation_code()
228     
229     def consume(self):
230         self.is_consumed = True
231         self.consumed = datetime.now()
232         self.save()
233
234     def __unicode__(self):
235         return '%s -> %s [%d]' % (self.inviter, self.username, self.code)
236
237 def report_user_event(user):
238     def should_send(user):
239         # report event incase of new user instance
240         # or if specific fields are modified
241         if not user.id:
242             return True
243         db_instance = AstakosUser.objects.get(id = user.id)
244         for f in BILLING_FIELDS:
245             if (db_instance.__getattribute__(f) != user.__getattribute__(f)):
246                 return True
247         return False
248
249     if QUEUE_CONNECTION and should_send(user):
250
251         from astakos.im.queue.userevent import UserEvent
252         from synnefo.lib.queue import exchange_connect, exchange_send, \
253                 exchange_close
254
255         eventType = 'create' if not user.id else 'modify'
256         body = UserEvent(QUEUE_CLIENT_ID, user, eventType, {}).format()
257         conn = exchange_connect(QUEUE_CONNECTION)
258         parts = urlparse(QUEUE_CONNECTION)
259         exchange = parts.path[1:]
260         routing_key = '%s.user' % exchange
261         exchange_send(conn, routing_key, body)
262         exchange_close(conn)
263
264 def _generate_invitation_code():
265     while True:
266         code = randint(1, 2L**63 - 1)
267         try:
268             Invitation.objects.get(code=code)
269             # An invitation with this code already exists, try again
270         except Invitation.DoesNotExist:
271             return code
272
273 def get_latest_terms():
274     try:
275         term = ApprovalTerms.objects.order_by('-id')[0]
276         return term
277     except IndexError:
278         pass
279     return None
280
281 class EmailChangeManager(models.Manager):
282     @transaction.commit_on_success
283     def change_email(self, activation_key):
284         """
285         Validate an activation key and change the corresponding
286         ``User`` if valid.
287
288         If the key is valid and has not expired, return the ``User``
289         after activating.
290
291         If the key is not valid or has expired, return ``None``.
292
293         If the key is valid but the ``User`` is already active,
294         return ``None``.
295
296         After successful email change the activation record is deleted.
297
298         Throws ValueError if there is already
299         """
300         try:
301             email_change = self.model.objects.get(activation_key=activation_key)
302             if email_change.activation_key_expired():
303                 email_change.delete()
304                 raise EmailChange.DoesNotExist
305             # is there an active user with this address?
306             try:
307                 AstakosUser.objects.get(email=email_change.new_email_address)
308             except AstakosUser.DoesNotExist:
309                 pass
310             else:
311                 raise ValueError(_('The new email address is reserved.'))
312             # update user
313             user = AstakosUser.objects.get(pk=email_change.user_id)
314             user.email = email_change.new_email_address
315             user.save()
316             email_change.delete()
317             return user
318         except EmailChange.DoesNotExist:
319             raise ValueError(_('Invalid activation key'))
320
321 class EmailChange(models.Model):
322     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.'))
323     user = models.ForeignKey(AstakosUser, unique=True, related_name='emailchange_user')
324     requested_at = models.DateTimeField(default=datetime.now())
325     activation_key = models.CharField(max_length=40, unique=True, db_index=True)
326
327     objects = EmailChangeManager()
328
329     def activation_key_expired(self):
330         expiration_date = timedelta(days=EMAILCHANGE_ACTIVATION_DAYS)
331         return self.requested_at + expiration_date < datetime.now()