28c76a56ca406a59773525abc51b4a1cd18ca3ab
[astakos] / snf-astakos-app / astakos / im / forms.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 from urlparse import urljoin
34 from datetime import datetime
35
36 from django import forms
37 from django.utils.translation import ugettext as _
38 from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, \
39     PasswordResetForm, PasswordChangeForm, SetPasswordForm
40 from django.core.mail import send_mail
41 from django.contrib.auth.tokens import default_token_generator
42 from django.template import Context, loader
43 from django.utils.http import int_to_base36
44 from django.core.urlresolvers import reverse
45 from django.utils.functional import lazy
46 from django.utils.safestring import mark_safe
47 from django.contrib import messages
48 from django.utils.encoding import smart_str
49
50 from astakos.im.models import AstakosUser, Invitation, get_latest_terms, EmailChange
51 from astakos.im.settings import (INVITATIONS_PER_LEVEL, DEFAULT_FROM_EMAIL,
52     BASEURL, SITENAME, RECAPTCHA_PRIVATE_KEY, DEFAULT_CONTACT_EMAIL,
53     RECAPTCHA_ENABLED, LOGGING_LEVEL, PASSWORD_RESET_EMAIL_SUBJECT,
54     NEWPASSWD_INVALIDATE_TOKEN, THIRDPARTY_ACC_ADDITIONAL_FIELDS
55 )
56 from astakos.im.widgets import DummyWidget, RecaptchaWidget
57 from astakos.im.functions import send_change_email
58
59 # since Django 1.4 use django.core.urlresolvers.reverse_lazy instead
60 from astakos.im.util import reverse_lazy, reserved_email, get_query
61
62 import logging
63 import hashlib
64 import recaptcha.client.captcha as captcha
65 from random import random
66
67 logger = logging.getLogger(__name__)
68
69 class LocalUserCreationForm(UserCreationForm):
70     """
71     Extends the built in UserCreationForm in several ways:
72
73     * Adds email, first_name, last_name, recaptcha_challenge_field, recaptcha_response_field field.
74     * The username field isn't visible and it is assigned a generated id.
75     * User created is not active.
76     """
77     recaptcha_challenge_field = forms.CharField(widget=DummyWidget)
78     recaptcha_response_field = forms.CharField(widget=RecaptchaWidget, label='')
79
80     class Meta:
81         model = AstakosUser
82         fields = ("email", "first_name", "last_name", "has_signed_terms", "has_signed_terms")
83
84     def __init__(self, *args, **kwargs):
85         """
86         Changes the order of fields, and removes the username field.
87         """
88         request = kwargs.get('request', None)
89         if request:
90             kwargs.pop('request')
91             self.ip = request.META.get('REMOTE_ADDR',
92                                        request.META.get('HTTP_X_REAL_IP', None))
93
94         super(LocalUserCreationForm, self).__init__(*args, **kwargs)
95         self.fields.keyOrder = ['email', 'first_name', 'last_name',
96                                 'password1', 'password2']
97
98         if RECAPTCHA_ENABLED:
99             self.fields.keyOrder.extend(['recaptcha_challenge_field',
100                                          'recaptcha_response_field',])
101         if get_latest_terms():
102             self.fields.keyOrder.append('has_signed_terms')
103
104         if 'has_signed_terms' in self.fields:
105             # Overriding field label since we need to apply a link
106             # to the terms within the label
107             terms_link_html = '<a href="%s" target="_blank">%s</a>' \
108                     % (reverse('latest_terms'), _("the terms"))
109             self.fields['has_signed_terms'].label = \
110                     mark_safe("I agree with %s" % terms_link_html)
111
112     def clean_email(self):
113         email = self.cleaned_data['email']
114         if not email:
115             raise forms.ValidationError(_("This field is required"))
116         if reserved_email(email):
117             raise forms.ValidationError(_("This email is already used"))
118         return email
119
120     def clean_has_signed_terms(self):
121         has_signed_terms = self.cleaned_data['has_signed_terms']
122         if not has_signed_terms:
123             raise forms.ValidationError(_('You have to agree with the terms'))
124         return has_signed_terms
125
126     def clean_recaptcha_response_field(self):
127         if 'recaptcha_challenge_field' in self.cleaned_data:
128             self.validate_captcha()
129         return self.cleaned_data['recaptcha_response_field']
130
131     def clean_recaptcha_challenge_field(self):
132         if 'recaptcha_response_field' in self.cleaned_data:
133             self.validate_captcha()
134         return self.cleaned_data['recaptcha_challenge_field']
135
136     def validate_captcha(self):
137         rcf = self.cleaned_data['recaptcha_challenge_field']
138         rrf = self.cleaned_data['recaptcha_response_field']
139         check = captcha.submit(rcf, rrf, RECAPTCHA_PRIVATE_KEY, self.ip)
140         if not check.is_valid:
141             raise forms.ValidationError(_('You have not entered the correct words'))
142
143     def save(self, commit=True):
144         """
145         Saves the email, first_name and last_name properties, after the normal
146         save behavior is complete.
147         """
148         user = super(LocalUserCreationForm, self).save(commit=False)
149         user.renew_token()
150         if commit:
151             user.save()
152             logger._log(LOGGING_LEVEL, 'Created user %s' % user.email, [])
153         return user
154
155 class InvitedLocalUserCreationForm(LocalUserCreationForm):
156     """
157     Extends the LocalUserCreationForm: email is readonly.
158     """
159     class Meta:
160         model = AstakosUser
161         fields = ("email", "first_name", "last_name", "has_signed_terms")
162
163     def __init__(self, *args, **kwargs):
164         """
165         Changes the order of fields, and removes the username field.
166         """
167         super(InvitedLocalUserCreationForm, self).__init__(*args, **kwargs)
168
169         #set readonly form fields
170         ro = ('email', 'username',)
171         for f in ro:
172             self.fields[f].widget.attrs['readonly'] = True
173
174
175     def save(self, commit=True):
176         user = super(InvitedLocalUserCreationForm, self).save(commit=False)
177         level = user.invitation.inviter.level + 1
178         user.level = level
179         user.invitations = INVITATIONS_PER_LEVEL.get(level, 0)
180         user.email_verified = True
181         if commit:
182             user.save()
183         return user
184
185 class ThirdPartyUserCreationForm(forms.ModelForm):
186     third_party_identifier = forms.CharField(
187         widget=forms.HiddenInput(),
188         label=''
189     )
190     class Meta:
191         model = AstakosUser
192         fields = ("email", "first_name", "last_name", "third_party_identifier", "has_signed_terms")
193
194     def __init__(self, *args, **kwargs):
195         """
196         Changes the order of fields, and removes the username field.
197         """
198         self.request = kwargs.get('request', None)
199         if self.request:
200             kwargs.pop('request')
201                 
202         latest_terms = get_latest_terms()
203         if latest_terms:
204             self._meta.fields.append('has_signed_terms')
205                 
206         super(ThirdPartyUserCreationForm, self).__init__(*args, **kwargs)
207         
208         if latest_terms:
209             self.fields.keyOrder.append('has_signed_terms')
210         
211         if 'has_signed_terms' in self.fields:
212             # Overriding field label since we need to apply a link
213             # to the terms within the label
214             terms_link_html = '<a href="%s" target="_blank">%s</a>' \
215                     % (reverse('latest_terms'), _("the terms"))
216             self.fields['has_signed_terms'].label = \
217                     mark_safe("I agree with %s" % terms_link_html)
218
219     def clean_email(self):
220         email = self.cleaned_data['email']
221         if not email:
222             raise forms.ValidationError(_("This field is required"))
223         return email
224
225     def clean_has_signed_terms(self):
226         has_signed_terms = self.cleaned_data['has_signed_terms']
227         if not has_signed_terms:
228             raise forms.ValidationError(_('You have to agree with the terms'))
229         return has_signed_terms
230
231     def save(self, commit=True):
232         user = super(ThirdPartyUserCreationForm, self).save(commit=False)
233         user.set_unusable_password()
234         user.renew_token()
235         user.provider = get_query(self.request).get('provider')
236         if commit:
237             user.save()
238             logger._log(LOGGING_LEVEL, 'Created user %s' % user.email, [])
239         return user
240
241 class InvitedThirdPartyUserCreationForm(ThirdPartyUserCreationForm):
242     """
243     Extends the ThirdPartyUserCreationForm: email is readonly.
244     """
245     def __init__(self, *args, **kwargs):
246         """
247         Changes the order of fields, and removes the username field.
248         """
249         super(InvitedThirdPartyUserCreationForm, self).__init__(*args, **kwargs)
250
251         #set readonly form fields
252         ro = ('email',)
253         for f in ro:
254             self.fields[f].widget.attrs['readonly'] = True
255
256     def save(self, commit=True):
257         user = super(InvitedThirdPartyUserCreationForm, self).save(commit=False)
258         level = user.invitation.inviter.level + 1
259         user.level = level
260         user.invitations = INVITATIONS_PER_LEVEL.get(level, 0)
261         user.email_verified = True
262         if commit:
263             user.save()
264         return user
265
266 class ShibbolethUserCreationForm(ThirdPartyUserCreationForm):
267     additional_email = forms.CharField(widget=forms.HiddenInput(), label='', required = False)
268
269     def __init__(self, *args, **kwargs):
270         super(ShibbolethUserCreationForm, self).__init__(*args, **kwargs)
271         # copy email value to additional_mail in case user will change it
272         name = 'email'
273         field = self.fields[name]
274         self.initial['additional_email'] = self.initial.get(name, field.initial)
275         self.initial['email'] = None
276     
277     def clean_email(self):
278         email = self.cleaned_data['email']
279         for user in AstakosUser.objects.filter(email = email):
280             if user.provider == 'shibboleth':
281                 raise forms.ValidationError(_(
282                         "This email is already associated with another shibboleth \
283                         account."
284                     )
285                 )
286             else:
287                 raise forms.ValidationError(_("This email is already used"))
288         super(ShibbolethUserCreationForm, self).clean_email()
289         return email
290
291 class InvitedShibbolethUserCreationForm(ShibbolethUserCreationForm, InvitedThirdPartyUserCreationForm):
292     pass
293
294 class LoginForm(AuthenticationForm):
295     username = forms.EmailField(label=_("Email"))
296     recaptcha_challenge_field = forms.CharField(widget=DummyWidget)
297     recaptcha_response_field = forms.CharField(widget=RecaptchaWidget, label='')
298
299     def __init__(self, *args, **kwargs):
300         was_limited = kwargs.get('was_limited', False)
301         request = kwargs.get('request', None)
302         if request:
303             self.ip = request.META.get('REMOTE_ADDR',
304                                        request.META.get('HTTP_X_REAL_IP', None))
305
306         t = ('request', 'was_limited')
307         for elem in t:
308             if elem in kwargs.keys():
309                 kwargs.pop(elem)
310         super(LoginForm, self).__init__(*args, **kwargs)
311
312         self.fields.keyOrder = ['username', 'password']
313         if was_limited and RECAPTCHA_ENABLED:
314             self.fields.keyOrder.extend(['recaptcha_challenge_field',
315                                          'recaptcha_response_field',])
316
317     def clean_recaptcha_response_field(self):
318         if 'recaptcha_challenge_field' in self.cleaned_data:
319             self.validate_captcha()
320         return self.cleaned_data['recaptcha_response_field']
321
322     def clean_recaptcha_challenge_field(self):
323         if 'recaptcha_response_field' in self.cleaned_data:
324             self.validate_captcha()
325         return self.cleaned_data['recaptcha_challenge_field']
326
327     def validate_captcha(self):
328         rcf = self.cleaned_data['recaptcha_challenge_field']
329         rrf = self.cleaned_data['recaptcha_response_field']
330         check = captcha.submit(rcf, rrf, RECAPTCHA_PRIVATE_KEY, self.ip)
331         if not check.is_valid:
332             raise forms.ValidationError(_('You have not entered the correct words'))
333     
334     def clean(self):
335         super(LoginForm, self).clean()
336         if self.user_cache and self.user_cache.provider not in ('local', ''):
337             raise forms.ValidationError(_('Local login is not the current authentication method for this account.'))
338         return self.cleaned_data
339
340 class ProfileForm(forms.ModelForm):
341     """
342     Subclass of ``ModelForm`` for permiting user to edit his/her profile.
343     Most of the fields are readonly since the user is not allowed to change them.
344
345     The class defines a save method which sets ``is_verified`` to True so as the user
346     during the next login will not to be redirected to profile page.
347     """
348     renew = forms.BooleanField(label='Renew token', required=False)
349
350     class Meta:
351         model = AstakosUser
352         fields = ('email', 'first_name', 'last_name', 'auth_token', 'auth_token_expires')
353
354     def __init__(self, *args, **kwargs):
355         super(ProfileForm, self).__init__(*args, **kwargs)
356         instance = getattr(self, 'instance', None)
357         ro_fields = ('email', 'auth_token', 'auth_token_expires')
358         if instance and instance.id:
359             for field in ro_fields:
360                 self.fields[field].widget.attrs['readonly'] = True
361
362     def save(self, commit=True):
363         user = super(ProfileForm, self).save(commit=False)
364         user.is_verified = True
365         if self.cleaned_data.get('renew'):
366             user.renew_token()
367         if commit:
368             user.save()
369         return user
370
371 class FeedbackForm(forms.Form):
372     """
373     Form for writing feedback.
374     """
375     feedback_msg = forms.CharField(widget=forms.Textarea, label=u'Message')
376     feedback_data = forms.CharField(widget=forms.HiddenInput(), label='',
377                                     required=False)
378
379 class SendInvitationForm(forms.Form):
380     """
381     Form for sending an invitations
382     """
383
384     email = forms.EmailField(required = True, label = 'Email address')
385     first_name = forms.EmailField(label = 'First name')
386     last_name = forms.EmailField(label = 'Last name')
387
388 class ExtendedPasswordResetForm(PasswordResetForm):
389     """
390     Extends PasswordResetForm by overriding save method:
391     passes a custom from_email in send_mail.
392
393     Since Django 1.3 this is useless since ``django.contrib.auth.views.reset_password``
394     accepts a from_email argument.
395     """
396     def clean_email(self):
397         email = super(ExtendedPasswordResetForm, self).clean_email()
398         try:
399             user = AstakosUser.objects.get(email=email, is_active=True)
400             if not user.has_usable_password():
401                 raise forms.ValidationError(_("This account has not a usable password."))
402         except AstakosUser.DoesNotExist, e:
403             raise forms.ValidationError(_('That e-mail address doesn\'t have an associated user account. Are you sure you\'ve registered?'))
404         return email
405
406     def save(self, domain_override=None, email_template_name='registration/password_reset_email.html',
407              use_https=False, token_generator=default_token_generator, request=None):
408         """
409         Generates a one-use only link for resetting password and sends to the user.
410         """
411         for user in self.users_cache:
412             url = reverse('django.contrib.auth.views.password_reset_confirm',
413                           kwargs={'uidb36':int_to_base36(user.id),
414                                   'token':token_generator.make_token(user)})
415             url = urljoin(BASEURL, url)
416             t = loader.get_template(email_template_name)
417             c = {
418                 'email': user.email,
419                 'url': url,
420                 'site_name': SITENAME,
421                 'user': user,
422                 'baseurl': BASEURL,
423                 'support': DEFAULT_CONTACT_EMAIL
424             }
425             from_email = DEFAULT_FROM_EMAIL
426             send_mail(_(PASSWORD_RESET_EMAIL_SUBJECT),
427                 t.render(Context(c)), from_email, [user.email])
428
429 class EmailChangeForm(forms.ModelForm):
430     class Meta:
431         model = EmailChange
432         fields = ('new_email_address',)
433
434     def clean_new_email_address(self):
435         addr = self.cleaned_data['new_email_address']
436         if AstakosUser.objects.filter(email__iexact=addr):
437             raise forms.ValidationError(_(u'This email address is already in use. Please supply a different email address.'))
438         return addr
439
440     def save(self, email_template_name, request, commit=True):
441         ec = super(EmailChangeForm, self).save(commit=False)
442         ec.user = request.user
443         activation_key = hashlib.sha1(str(random()) + smart_str(ec.new_email_address))
444         ec.activation_key=activation_key.hexdigest()
445         if commit:
446             ec.save()
447         send_change_email(ec, request, email_template_name=email_template_name)
448
449 class SignApprovalTermsForm(forms.ModelForm):
450     class Meta:
451         model = AstakosUser
452         fields = ("has_signed_terms",)
453
454     def __init__(self, *args, **kwargs):
455         super(SignApprovalTermsForm, self).__init__(*args, **kwargs)
456
457     def clean_has_signed_terms(self):
458         has_signed_terms = self.cleaned_data['has_signed_terms']
459         if not has_signed_terms:
460             raise forms.ValidationError(_('You have to agree with the terms'))
461         return has_signed_terms
462
463 class InvitationForm(forms.ModelForm):
464     username = forms.EmailField(label=_("Email"))
465
466     def __init__(self, *args, **kwargs):
467         super(InvitationForm, self).__init__(*args, **kwargs)
468
469     class Meta:
470         model = Invitation
471         fields = ('username', 'realname')
472
473     def clean_username(self):
474         username = self.cleaned_data['username']
475         try:
476             Invitation.objects.get(username = username)
477             raise forms.ValidationError(_('There is already invitation for this email.'))
478         except Invitation.DoesNotExist:
479             pass
480         return username
481
482 class ExtendedPasswordChangeForm(PasswordChangeForm):
483     """
484     Extends PasswordChangeForm by enabling user
485     to optionally renew also the token.
486     """
487     if not NEWPASSWD_INVALIDATE_TOKEN:
488         renew = forms.BooleanField(label='Renew token', required=False,
489                                    initial=True,
490                                    help_text='Unsetting this may result in security risk.')
491
492     def __init__(self, user, *args, **kwargs):
493         super(ExtendedPasswordChangeForm, self).__init__(user, *args, **kwargs)
494
495     def save(self, commit=True):
496         if NEWPASSWD_INVALIDATE_TOKEN or self.cleaned_data.get('renew'):
497             self.user.renew_token()
498         return super(ExtendedPasswordChangeForm, self).save(commit=commit)
499
500 class ExtendedSetPasswordForm(SetPasswordForm):
501     """
502     Extends SetPasswordForm by enabling user
503     to optionally renew also the token.
504     """
505     if not NEWPASSWD_INVALIDATE_TOKEN:
506         renew = forms.BooleanField(label='Renew token', required=False,
507                                    initial=True,
508                                    help_text='Unsetting this may result in security risk.')
509     
510     def __init__(self, user, *args, **kwargs):
511         super(ExtendedSetPasswordForm, self).__init__(user, *args, **kwargs)
512     
513     def save(self, commit=True):
514         if NEWPASSWD_INVALIDATE_TOKEN or self.cleaned_data.get('renew'):
515             if isinstance(self.user, AstakosUser):
516                 self.user.renew_token()
517         return super(ExtendedSetPasswordForm, self).save(commit=commit)