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