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