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