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