Configurable subjects for all emails send by astakos
[astakos] / snf-astakos-app / astakos / im / forms.py
index 61d166d..e8a041c 100644 (file)
@@ -1,18 +1,18 @@
 # Copyright 2011-2012 GRNET S.A. All rights reserved.
-# 
+#
 # Redistribution and use in source and binary forms, with or
 # without modification, are permitted provided that the following
 # conditions are met:
-# 
+#
 #   1. Redistributions of source code must retain the above
 #      copyright notice, this list of conditions and the following
 #      disclaimer.
-# 
+#
 #   2. Redistributions in binary form must reproduce the above
 #      copyright notice, this list of conditions and the following
 #      disclaimer in the documentation and/or other materials
 #      provided with the distribution.
-# 
+#
 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
@@ -25,7 +25,7 @@
 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 # POSSIBILITY OF SUCH DAMAGE.
-# 
+#
 # The views and conclusions contained in the software and
 # documentation are those of the authors and should not be
 # interpreted as representing official policies, either expressed
@@ -35,74 +35,92 @@ from datetime import datetime
 
 from django import forms
 from django.utils.translation import ugettext as _
-from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, PasswordResetForm
+from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, \
+    PasswordResetForm, PasswordChangeForm
 from django.core.mail import send_mail
 from django.contrib.auth.tokens import default_token_generator
 from django.template import Context, loader
 from django.utils.http import int_to_base36
 from django.core.urlresolvers import reverse
 from django.utils.functional import lazy
+from django.utils.safestring import mark_safe
+from django.contrib import messages
+from django.utils.encoding import smart_str
 
-from astakos.im.models import AstakosUser, Invitation
-from astakos.im.settings import INVITATIONS_PER_LEVEL, DEFAULT_FROM_EMAIL, BASEURL, SITENAME, RECAPTCHA_PRIVATE_KEY, DEFAULT_CONTACT_EMAIL, RECAPTCHA_ENABLED
-from astakos.im.widgets import DummyWidget, RecaptchaWidget, ApprovalTermsWidget
+from astakos.im.models import AstakosUser, Invitation, get_latest_terms, EmailChange
+from astakos.im.settings import INVITATIONS_PER_LEVEL, DEFAULT_FROM_EMAIL, \
+    BASEURL, SITENAME, RECAPTCHA_PRIVATE_KEY, DEFAULT_CONTACT_EMAIL, \
+    RECAPTCHA_ENABLED, LOGGING_LEVEL, PASSWORD_RESET_EMAIL_SUBJECT
+from astakos.im.widgets import DummyWidget, RecaptchaWidget
+from astakos.im.functions import send_change_email
 
 # since Django 1.4 use django.core.urlresolvers.reverse_lazy instead
-from astakos.im.util import reverse_lazy, get_latest_terms
+from astakos.im.util import reverse_lazy, reserved_email, get_query
 
 import logging
+import hashlib
 import recaptcha.client.captcha as captcha
+from random import random
 
 logger = logging.getLogger(__name__)
 
 class LocalUserCreationForm(UserCreationForm):
     """
     Extends the built in UserCreationForm in several ways:
-    
+
     * Adds email, first_name, last_name, recaptcha_challenge_field, recaptcha_response_field field.
     * The username field isn't visible and it is assigned a generated id.
-    * User created is not active. 
+    * User created is not active.
     """
     recaptcha_challenge_field = forms.CharField(widget=DummyWidget)
     recaptcha_response_field = forms.CharField(widget=RecaptchaWidget, label='')
-    
+
     class Meta:
         model = AstakosUser
-        fields = ("email", "first_name", "last_name", "has_signed_terms")
-        widgets = {"has_signed_terms":ApprovalTermsWidget(terms_uri=reverse_lazy('latest_terms'))}
-    
+        fields = ("email", "first_name", "last_name", "has_signed_terms", "has_signed_terms")
+
     def __init__(self, *args, **kwargs):
         """
         Changes the order of fields, and removes the username field.
         """
-        if 'ip' in kwargs:
-            self.ip = kwargs['ip']
-            kwargs.pop('ip')
+        request = kwargs.get('request', None)
+        if request:
+            kwargs.pop('request')
+            self.ip = request.META.get('REMOTE_ADDR',
+                                       request.META.get('HTTP_X_REAL_IP', None))
+
         super(LocalUserCreationForm, self).__init__(*args, **kwargs)
         self.fields.keyOrder = ['email', 'first_name', 'last_name',
                                 'password1', 'password2']
-        if get_latest_terms():
-            self.fields.keyOrder.append('has_signed_terms')
+
         if RECAPTCHA_ENABLED:
             self.fields.keyOrder.extend(['recaptcha_challenge_field',
                                          'recaptcha_response_field',])
-    
+        if get_latest_terms():
+            self.fields.keyOrder.append('has_signed_terms')
+
+        if 'has_signed_terms' in self.fields:
+            # Overriding field label since we need to apply a link
+            # to the terms within the label
+            terms_link_html = '<a href="%s" target="_blank">%s</a>' \
+                    % (reverse('latest_terms'), _("the terms"))
+            self.fields['has_signed_terms'].label = \
+                    mark_safe("I agree with %s" % terms_link_html)
+
     def clean_email(self):
         email = self.cleaned_data['email']
         if not email:
             raise forms.ValidationError(_("This field is required"))
-        try:
-            AstakosUser.objects.get(email = email)
+        if reserved_email(email):
             raise forms.ValidationError(_("This email is already used"))
-        except AstakosUser.DoesNotExist:
-            return email
-    
+        return email
+
     def clean_has_signed_terms(self):
         has_signed_terms = self.cleaned_data['has_signed_terms']
         if not has_signed_terms:
             raise forms.ValidationError(_('You have to agree with the terms'))
         return has_signed_terms
-    
+
     def clean_recaptcha_response_field(self):
         if 'recaptcha_challenge_field' in self.cleaned_data:
             self.validate_captcha()
@@ -119,7 +137,7 @@ class LocalUserCreationForm(UserCreationForm):
         check = captcha.submit(rcf, rrf, RECAPTCHA_PRIVATE_KEY, self.ip)
         if not check.is_valid:
             raise forms.ValidationError(_('You have not entered the correct words'))
-    
+
     def save(self, commit=True):
         """
         Saves the email, first_name and last_name properties, after the normal
@@ -127,35 +145,31 @@ class LocalUserCreationForm(UserCreationForm):
         """
         user = super(LocalUserCreationForm, self).save(commit=False)
         user.renew_token()
-        user.date_signed_terms = datetime.now()
         if commit:
             user.save()
-        logger.info('Created user %s', user)
+            logger._log(LOGGING_LEVEL, 'Created user %s' % user.email, [])
         return user
 
 class InvitedLocalUserCreationForm(LocalUserCreationForm):
     """
-    Extends the LocalUserCreationForm: adds an inviter readonly field.
+    Extends the LocalUserCreationForm: email is readonly.
     """
-    
-    inviter = forms.CharField(widget=forms.TextInput(), label=_('Inviter Real Name'))
-    
     class Meta:
         model = AstakosUser
         fields = ("email", "first_name", "last_name", "has_signed_terms")
-        widgets = {"has_signed_terms":ApprovalTermsWidget(terms_uri=reverse_lazy('latest_terms'))}
-    
+
     def __init__(self, *args, **kwargs):
         """
         Changes the order of fields, and removes the username field.
         """
         super(InvitedLocalUserCreationForm, self).__init__(*args, **kwargs)
-        
+
         #set readonly form fields
-        self.fields['inviter'].widget.attrs['readonly'] = True
-        self.fields['email'].widget.attrs['readonly'] = True
-        self.fields['username'].widget.attrs['readonly'] = True
-    
+        ro = ('email', 'username',)
+        for f in ro:
+            self.fields[f].widget.attrs['readonly'] = True
+
+
     def save(self, commit=True):
         user = super(InvitedLocalUserCreationForm, self).save(commit=False)
         level = user.invitation.inviter.level + 1
@@ -166,63 +180,175 @@ class InvitedLocalUserCreationForm(LocalUserCreationForm):
             user.save()
         return user
 
-class ThirdPartyUserCreationForm(UserCreationForm):
+class ThirdPartyUserCreationForm(forms.ModelForm):
     class Meta:
         model = AstakosUser
-        fields = ("email", "has_signed_terms")
-        widgets = {"has_signed_terms":ApprovalTermsWidget(terms_uri=reverse_lazy('latest_terms'))}
-    
+        fields = ("email", "first_name", "last_name", "third_party_identifier", "has_signed_terms")
+
     def __init__(self, *args, **kwargs):
         """
         Changes the order of fields, and removes the username field.
         """
-        if 'ip' in kwargs:
-            self.ip = kwargs['ip']
-            kwargs.pop('ip')
+        self.request = kwargs.get('request', None)
+        if self.request:
+            kwargs.pop('request')
         super(ThirdPartyUserCreationForm, self).__init__(*args, **kwargs)
-        self.fields.keyOrder = ['email']
+        self.fields.keyOrder = ['email', 'first_name', 'last_name', 'third_party_identifier']
         if get_latest_terms():
             self.fields.keyOrder.append('has_signed_terms')
-    
+        #set readonly form fields
+        ro = ["third_party_identifier"]
+        for f in ro:
+            self.fields[f].widget.attrs['readonly'] = True
+
+        if 'has_signed_terms' in self.fields:
+            # Overriding field label since we need to apply a link
+            # to the terms within the label
+            terms_link_html = '<a href="%s" target="_blank">%s</a>' \
+                    % (reverse('latest_terms'), _("the terms"))
+            self.fields['has_signed_terms'].label = \
+                    mark_safe("I agree with %s" % terms_link_html)
+
+    def clean_email(self):
+        email = self.cleaned_data['email']
+        if not email:
+            raise forms.ValidationError(_("This field is required"))
+        return email
+
+    def clean_has_signed_terms(self):
+        has_signed_terms = self.cleaned_data['has_signed_terms']
+        if not has_signed_terms:
+            raise forms.ValidationError(_('You have to agree with the terms'))
+        return has_signed_terms
+
     def save(self, commit=True):
         user = super(ThirdPartyUserCreationForm, self).save(commit=False)
         user.set_unusable_password()
+        user.renew_token()
+        user.provider = get_query(self.request).get('provider')
         if commit:
             user.save()
-        logger.info('Created user %s', user)
+            logger._log(LOGGING_LEVEL, 'Created user %s' % user.email, [])
         return user
 
 class InvitedThirdPartyUserCreationForm(ThirdPartyUserCreationForm):
+    """
+    Extends the ThirdPartyUserCreationForm: email is readonly.
+    """
     def __init__(self, *args, **kwargs):
+        """
+        Changes the order of fields, and removes the username field.
+        """
         super(InvitedThirdPartyUserCreationForm, self).__init__(*args, **kwargs)
+
         #set readonly form fields
-        self.fields['email'].widget.attrs['readonly'] = True
+        ro = ('email',)
+        for f in ro:
+            self.fields[f].widget.attrs['readonly'] = True
+
+    def save(self, commit=True):
+        user = super(InvitedThirdPartyUserCreationForm, self).save(commit=False)
+        level = user.invitation.inviter.level + 1
+        user.level = level
+        user.invitations = INVITATIONS_PER_LEVEL.get(level, 0)
+        user.email_verified = True
+        if commit:
+            user.save()
+        return user
+
+class ShibbolethUserCreationForm(ThirdPartyUserCreationForm):
+    additional_email = forms.CharField(widget=forms.HiddenInput(), label='', required = False)
+
+    def __init__(self, *args, **kwargs):
+        super(ShibbolethUserCreationForm, self).__init__(*args, **kwargs)
+        self.fields.keyOrder.append('additional_email')
+        # copy email value to additional_mail in case user will change it
+        name = 'email'
+        field = self.fields[name]
+        self.initial['additional_email'] = self.initial.get(name, field.initial)
+
+    def clean_email(self):
+        email = self.cleaned_data['email']
+        for user in AstakosUser.objects.filter(email = email):
+            if user.provider == 'shibboleth':
+                raise forms.ValidationError(_("This email is already associated with another shibboleth account."))
+            elif not user.is_active:
+                raise forms.ValidationError(_("This email is already associated with an inactive account. \
+                                              You need to wait to be activated before being able to switch to a shibboleth account."))
+        super(ShibbolethUserCreationForm, self).clean_email()
+        return email
+
+class InvitedShibbolethUserCreationForm(ShibbolethUserCreationForm, InvitedThirdPartyUserCreationForm):
+    pass
 
 class LoginForm(AuthenticationForm):
     username = forms.EmailField(label=_("Email"))
+    recaptcha_challenge_field = forms.CharField(widget=DummyWidget)
+    recaptcha_response_field = forms.CharField(widget=RecaptchaWidget, label='')
+
+    def __init__(self, *args, **kwargs):
+        was_limited = kwargs.get('was_limited', False)
+        request = kwargs.get('request', None)
+        if request:
+            self.ip = request.META.get('REMOTE_ADDR',
+                                       request.META.get('HTTP_X_REAL_IP', None))
+
+        t = ('request', 'was_limited')
+        for elem in t:
+            if elem in kwargs.keys():
+                kwargs.pop(elem)
+        super(LoginForm, self).__init__(*args, **kwargs)
+
+        self.fields.keyOrder = ['username', 'password']
+        if was_limited and RECAPTCHA_ENABLED:
+            self.fields.keyOrder.extend(['recaptcha_challenge_field',
+                                         'recaptcha_response_field',])
+
+    def clean_recaptcha_response_field(self):
+        if 'recaptcha_challenge_field' in self.cleaned_data:
+            self.validate_captcha()
+        return self.cleaned_data['recaptcha_response_field']
+
+    def clean_recaptcha_challenge_field(self):
+        if 'recaptcha_response_field' in self.cleaned_data:
+            self.validate_captcha()
+        return self.cleaned_data['recaptcha_challenge_field']
+
+    def validate_captcha(self):
+        rcf = self.cleaned_data['recaptcha_challenge_field']
+        rrf = self.cleaned_data['recaptcha_response_field']
+        check = captcha.submit(rcf, rrf, RECAPTCHA_PRIVATE_KEY, self.ip)
+        if not check.is_valid:
+            raise forms.ValidationError(_('You have not entered the correct words'))
+
+    def clean(self):
+        super(LoginForm, self).clean()
+        if self.user_cache and self.user_cache.provider not in ('local', ''):
+            raise forms.ValidationError(_('Local login is not the current authentication method for this account.'))
+        return self.cleaned_data
 
 class ProfileForm(forms.ModelForm):
     """
     Subclass of ``ModelForm`` for permiting user to edit his/her profile.
     Most of the fields are readonly since the user is not allowed to change them.
-    
+
     The class defines a save method which sets ``is_verified`` to True so as the user
     during the next login will not to be redirected to profile page.
     """
     renew = forms.BooleanField(label='Renew token', required=False)
-    
+
     class Meta:
         model = AstakosUser
         fields = ('email', 'first_name', 'last_name', 'auth_token', 'auth_token_expires')
-    
+
     def __init__(self, *args, **kwargs):
         super(ProfileForm, self).__init__(*args, **kwargs)
         instance = getattr(self, 'instance', None)
-        ro_fields = ('auth_token', 'auth_token_expires', 'email')
+        ro_fields = ('email', 'auth_token', 'auth_token_expires')
         if instance and instance.id:
             for field in ro_fields:
                 self.fields[field].widget.attrs['readonly'] = True
-    
+
     def save(self, commit=True):
         user = super(ProfileForm, self).save(commit=False)
         user.is_verified = True
@@ -236,7 +362,7 @@ class FeedbackForm(forms.Form):
     """
     Form for writing feedback.
     """
-    feedback_msg = forms.CharField(widget=forms.TextInput(), label=u'Message')
+    feedback_msg = forms.CharField(widget=forms.Textarea, label=u'Message')
     feedback_data = forms.CharField(widget=forms.HiddenInput(), label='',
                                     required=False)
 
@@ -244,7 +370,7 @@ class SendInvitationForm(forms.Form):
     """
     Form for sending an invitations
     """
-    
+
     email = forms.EmailField(required = True, label = 'Email address')
     first_name = forms.EmailField(label = 'First name')
     last_name = forms.EmailField(label = 'Last name')
@@ -253,19 +379,30 @@ class ExtendedPasswordResetForm(PasswordResetForm):
     """
     Extends PasswordResetForm by overriding save method:
     passes a custom from_email in send_mail.
-    
+
     Since Django 1.3 this is useless since ``django.contrib.auth.views.reset_password``
     accepts a from_email argument.
     """
+    def clean_email(self):
+        email = super(ExtendedPasswordResetForm, self).clean_email()
+        try:
+            user = AstakosUser.objects.get(email=email, is_active=True)
+            if not user.has_usable_password():
+                raise forms.ValidationError(_("This account has not a usable password."))
+        except AstakosUser.DoesNotExist, e:
+            raise forms.ValidationError(_('That e-mail address doesn\'t have an associated user account. Are you sure you\'ve registered?'))
+        return email
+
     def save(self, domain_override=None, email_template_name='registration/password_reset_email.html',
              use_https=False, token_generator=default_token_generator, request=None):
         """
         Generates a one-use only link for resetting password and sends to the user.
         """
         for user in self.users_cache:
-            url = urljoin(BASEURL,
-                          '/im/local/reset/confirm/%s-%s' %(int_to_base36(user.id),
-                                                            token_generator.make_token(user)))
+            url = reverse('django.contrib.auth.views.password_reset_confirm',
+                          kwargs={'uidb36':int_to_base36(user.id),
+                                  'token':token_generator.make_token(user)})
+            url = urljoin(BASEURL, url)
             t = loader.get_template(email_template_name)
             c = {
                 'email': user.email,
@@ -276,40 +413,53 @@ class ExtendedPasswordResetForm(PasswordResetForm):
                 'support': DEFAULT_CONTACT_EMAIL
             }
             from_email = DEFAULT_FROM_EMAIL
-            send_mail(_("Password reset on %s alpha2 testing") % SITENAME,
+            send_mail(_(PASSWORD_RESET_EMAIL_SUBJECT),
                 t.render(Context(c)), from_email, [user.email])
 
+class EmailChangeForm(forms.ModelForm):
+    class Meta:
+        model = EmailChange
+        fields = ('new_email_address',)
+
+    def clean_new_email_address(self):
+        addr = self.cleaned_data['new_email_address']
+        if AstakosUser.objects.filter(email__iexact=addr):
+            raise forms.ValidationError(_(u'This email address is already in use. Please supply a different email address.'))
+        return addr
+
+    def save(self, email_template_name, request, commit=True):
+        ec = super(EmailChangeForm, self).save(commit=False)
+        ec.user = request.user
+        activation_key = hashlib.sha1(str(random()) + smart_str(ec.new_email_address))
+        ec.activation_key=activation_key.hexdigest()
+        if commit:
+            ec.save()
+        send_change_email(ec, request, email_template_name=email_template_name)
+
 class SignApprovalTermsForm(forms.ModelForm):
     class Meta:
         model = AstakosUser
         fields = ("has_signed_terms",)
-    
+
     def __init__(self, *args, **kwargs):
         super(SignApprovalTermsForm, self).__init__(*args, **kwargs)
-    
+
     def clean_has_signed_terms(self):
         has_signed_terms = self.cleaned_data['has_signed_terms']
         if not has_signed_terms:
             raise forms.ValidationError(_('You have to agree with the terms'))
         return has_signed_terms
-    
-    def save(self, commit=True):
-        """
-        Updates date_signed_terms & has_signed_terms fields.
-        """
-        user = super(SignApprovalTermsForm, self).save(commit=False)
-        user.date_signed_terms = datetime.now()
-        if commit:
-            user.save()
-        return user
 
 class InvitationForm(forms.ModelForm):
     username = forms.EmailField(label=_("Email"))
-    
+
+    def __init__(self, *args, **kwargs):
+        super(InvitationForm, self).__init__(*args, **kwargs)
+
     class Meta:
         model = Invitation
         fields = ('username', 'realname')
-    
+
     def clean_username(self):
         username = self.cleaned_data['username']
         try:
@@ -317,4 +467,22 @@ class InvitationForm(forms.ModelForm):
             raise forms.ValidationError(_('There is already invitation for this email.'))
         except Invitation.DoesNotExist:
             pass
-        return username
\ No newline at end of file
+        return username
+
+class ExtendedPasswordChangeForm(PasswordChangeForm):
+    """
+    Extends PasswordChangeForm by enabling user
+    to optionally renew also the token.
+    """
+    renew = forms.BooleanField(label='Renew token', required=False)
+
+    def __init__(self, user, *args, **kwargs):
+        super(ExtendedPasswordChangeForm, self).__init__(user, *args, **kwargs)
+
+    def save(self, commit=True):
+        user = super(ExtendedPasswordChangeForm, self).save(commit=False)
+        if self.cleaned_data.get('renew'):
+            user.renew_token()
+        if commit:
+            user.save()
+        return user