Display calendar in timeline date fields
[astakos] / snf-astakos-app / astakos / im / forms.py
1 # Copyright 2011-2012 GRNET S.A. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
5 # conditions are met:
6 #
7 #   1. Redistributions of source code must retain the above
8 #      copyright notice, this list of conditions and the following
9 #      disclaimer.
10 #
11 #   2. Redistributions in binary form must reproduce the above
12 #      copyright notice, this list of conditions and the following
13 #      disclaimer in the documentation and/or other materials
14 #      provided with the distribution.
15 #
16 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27 # POSSIBILITY OF SUCH DAMAGE.
28 #
29 # The views and conclusions contained in the software and
30 # documentation are those of the authors and should not be
31 # interpreted as representing official policies, either expressed
32 # or implied, of GRNET S.A.
33 from urlparse import urljoin
34
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 (AstakosUser, EmailChange, AstakosGroup,
51                                Invitation, Membership, GroupKind, Resource,
52                                get_latest_terms)
53 from astakos.im.settings import (INVITATIONS_PER_LEVEL, BASEURL, SITENAME,
54                                  RECAPTCHA_PRIVATE_KEY, RECAPTCHA_ENABLED,
55                                  DEFAULT_CONTACT_EMAIL, LOGGING_LEVEL)
56
57 from astakos.im.widgets import DummyWidget, RecaptchaWidget
58 from astakos.im.functions import send_change_email
59
60 from astakos.im.util import reserved_email, get_query
61
62 import logging
63 import hashlib
64 import recaptcha.client.captcha as captcha
65 from random import random
66
67 logger = logging.getLogger(__name__)
68
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(
80         widget=RecaptchaWidget, label='')
81
82     class Meta:
83         model = AstakosUser
84         fields = ("email", "first_name", "last_name",
85                   "has_signed_terms", "has_signed_terms")
86
87     def __init__(self, *args, **kwargs):
88         """
89         Changes the order of fields, and removes the username field.
90         """
91         request = kwargs.get('request', None)
92         if request:
93             kwargs.pop('request')
94             self.ip = request.META.get('REMOTE_ADDR',
95                                        request.META.get('HTTP_X_REAL_IP', None))
96
97         super(LocalUserCreationForm, self).__init__(*args, **kwargs)
98         self.fields.keyOrder = ['email', 'first_name', 'last_name',
99                                 'password1', 'password2']
100
101         if RECAPTCHA_ENABLED:
102             self.fields.keyOrder.extend(['recaptcha_challenge_field',
103                                          'recaptcha_response_field', ])
104         if get_latest_terms():
105             self.fields.keyOrder.append('has_signed_terms')
106
107         if 'has_signed_terms' in self.fields:
108             # Overriding field label since we need to apply a link
109             # to the terms within the label
110             terms_link_html = '<a href="%s" target="_blank">%s</a>' \
111                 % (reverse('latest_terms'), _("the terms"))
112             self.fields['has_signed_terms'].label = \
113                 mark_safe("I agree with %s" % terms_link_html)
114
115     def clean_email(self):
116         email = self.cleaned_data['email']
117         if not email:
118             raise forms.ValidationError(_("This field is required"))
119         if reserved_email(email):
120             raise forms.ValidationError(_("This email is already used"))
121         return email
122
123     def clean_has_signed_terms(self):
124         has_signed_terms = self.cleaned_data['has_signed_terms']
125         if not has_signed_terms:
126             raise forms.ValidationError(_('You have to agree with the terms'))
127         return has_signed_terms
128
129     def clean_recaptcha_response_field(self):
130         if 'recaptcha_challenge_field' in self.cleaned_data:
131             self.validate_captcha()
132         return self.cleaned_data['recaptcha_response_field']
133
134     def clean_recaptcha_challenge_field(self):
135         if 'recaptcha_response_field' in self.cleaned_data:
136             self.validate_captcha()
137         return self.cleaned_data['recaptcha_challenge_field']
138
139     def validate_captcha(self):
140         rcf = self.cleaned_data['recaptcha_challenge_field']
141         rrf = self.cleaned_data['recaptcha_response_field']
142         check = captcha.submit(rcf, rrf, RECAPTCHA_PRIVATE_KEY, self.ip)
143         if not check.is_valid:
144             raise forms.ValidationError(
145                 _('You have not entered the correct words'))
146
147     def save(self, commit=True):
148         """
149         Saves the email, first_name and last_name properties, after the normal
150         save behavior is complete.
151         """
152         user = super(LocalUserCreationForm, self).save(commit=False)
153         user.renew_token()
154         if commit:
155             user.save()
156             logger.log(LOGGING_LEVEL, 'Created user %s' % user.email)
157         return user
158
159
160 class InvitedLocalUserCreationForm(LocalUserCreationForm):
161     """
162     Extends the LocalUserCreationForm: email is readonly.
163     """
164     class Meta:
165         model = AstakosUser
166         fields = ("email", "first_name", "last_name", "has_signed_terms")
167
168     def __init__(self, *args, **kwargs):
169         """
170         Changes the order of fields, and removes the username field.
171         """
172         super(InvitedLocalUserCreationForm, self).__init__(*args, **kwargs)
173
174         #set readonly form fields
175         ro = ('email', 'username',)
176         for f in ro:
177             self.fields[f].widget.attrs['readonly'] = True
178     
179     def save(self, commit=True):
180         user = super(InvitedLocalUserCreationForm, self).save(commit=False)
181         level = user.invitation.inviter.level + 1
182         user.level = level
183         user.invitations = INVITATIONS_PER_LEVEL.get(level, 0)
184         user.email_verified = True
185         if commit:
186             user.save()
187         return user
188
189
190 class ThirdPartyUserCreationForm(forms.ModelForm):
191     class Meta:
192         model = AstakosUser
193         fields = ("email", "first_name", "last_name",
194                   "third_party_identifier", "has_signed_terms")
195     
196     def __init__(self, *args, **kwargs):
197         """
198         Changes the order of fields, and removes the username field.
199         """
200         self.request = kwargs.get('request', None)
201         if self.request:
202             kwargs.pop('request')
203         super(ThirdPartyUserCreationForm, self).__init__(*args, **kwargs)
204         self.fields.keyOrder = ['email', 'first_name', 'last_name',
205                                 'third_party_identifier']
206         if get_latest_terms():
207             self.fields.keyOrder.append('has_signed_terms')
208         #set readonly form fields
209         ro = ["third_party_identifier"]
210         for f in ro:
211             self.fields[f].widget.attrs['readonly'] = True
212
213         if 'has_signed_terms' in self.fields:
214             # Overriding field label since we need to apply a link
215             # to the terms within the label
216             terms_link_html = '<a href="%s" target="_blank">%s</a>' \
217                 % (reverse('latest_terms'), _("the terms"))
218             self.fields['has_signed_terms'].label = \
219                 mark_safe("I agree with %s" % terms_link_html)
220     
221     def clean_email(self):
222         email = self.cleaned_data['email']
223         if not email:
224             raise forms.ValidationError(_("This field is required"))
225         return email
226
227     def clean_has_signed_terms(self):
228         has_signed_terms = self.cleaned_data['has_signed_terms']
229         if not has_signed_terms:
230             raise forms.ValidationError(_('You have to agree with the terms'))
231         return has_signed_terms
232
233     def save(self, commit=True):
234         user = super(ThirdPartyUserCreationForm, self).save(commit=False)
235         user.set_unusable_password()
236         user.renew_token()
237         user.provider = get_query(self.request).get('provider')
238         if commit:
239             user.save()
240             logger.log(LOGGING_LEVEL, 'Created user %s' % user.email)
241         return user
242
243
244 class InvitedThirdPartyUserCreationForm(ThirdPartyUserCreationForm):
245     """
246     Extends the ThirdPartyUserCreationForm: email is readonly.
247     """
248     def __init__(self, *args, **kwargs):
249         """
250         Changes the order of fields, and removes the username field.
251         """
252         super(
253             InvitedThirdPartyUserCreationForm, self).__init__(*args, **kwargs)
254
255         #set readonly form fields
256         ro = ('email',)
257         for f in ro:
258             self.fields[f].widget.attrs['readonly'] = True
259
260     def save(self, commit=True):
261         user = super(
262             InvitedThirdPartyUserCreationForm, self).save(commit=False)
263         level = user.invitation.inviter.level + 1
264         user.level = level
265         user.invitations = INVITATIONS_PER_LEVEL.get(level, 0)
266         user.email_verified = True
267         if commit:
268             user.save()
269         return user
270
271
272 class ShibbolethUserCreationForm(ThirdPartyUserCreationForm):
273     additional_email = forms.CharField(
274         widget=forms.HiddenInput(), label='', required=False)
275     
276     def __init__(self, *args, **kwargs):
277         super(ShibbolethUserCreationForm, self).__init__(*args, **kwargs)
278         self.fields.keyOrder.append('additional_email')
279         # copy email value to additional_mail in case user will change it
280         name = 'email'
281         field = self.fields[name]
282         self.initial['additional_email'] = self.initial.get(name,
283                                                             field.initial)
284     
285     def clean_email(self):
286         email = self.cleaned_data['email']
287         for user in AstakosUser.objects.filter(email=email):
288             if user.provider == 'shibboleth':
289                 raise forms.ValidationError(_("This email is already associated with another shibboleth account."))
290             elif not user.is_active:
291                 raise forms.ValidationError(_("This email is already associated with an inactive account. \
292                                               You need to wait to be activated before being able to switch to a shibboleth account."))
293         super(ShibbolethUserCreationForm, self).clean_email()
294         return email
295
296
297 class InvitedShibbolethUserCreationForm(ShibbolethUserCreationForm, InvitedThirdPartyUserCreationForm):
298     pass
299
300
301 class LoginForm(AuthenticationForm):
302     username = forms.EmailField(label=_("Email"))
303     recaptcha_challenge_field = forms.CharField(widget=DummyWidget)
304     recaptcha_response_field = forms.CharField(
305         widget=RecaptchaWidget, label='')
306     
307     def __init__(self, *args, **kwargs):
308         was_limited = kwargs.get('was_limited', False)
309         request = kwargs.get('request', None)
310         if request:
311             self.ip = request.META.get('REMOTE_ADDR',
312                                        request.META.get('HTTP_X_REAL_IP', None))
313
314         t = ('request', 'was_limited')
315         for elem in t:
316             if elem in kwargs.keys():
317                 kwargs.pop(elem)
318         super(LoginForm, self).__init__(*args, **kwargs)
319
320         self.fields.keyOrder = ['username', 'password']
321         if was_limited and RECAPTCHA_ENABLED:
322             self.fields.keyOrder.extend(['recaptcha_challenge_field',
323                                          'recaptcha_response_field', ])
324     
325     def clean_recaptcha_response_field(self):
326         if 'recaptcha_challenge_field' in self.cleaned_data:
327             self.validate_captcha()
328         return self.cleaned_data['recaptcha_response_field']
329
330     def clean_recaptcha_challenge_field(self):
331         if 'recaptcha_response_field' in self.cleaned_data:
332             self.validate_captcha()
333         return self.cleaned_data['recaptcha_challenge_field']
334
335     def validate_captcha(self):
336         rcf = self.cleaned_data['recaptcha_challenge_field']
337         rrf = self.cleaned_data['recaptcha_response_field']
338         check = captcha.submit(rcf, rrf, RECAPTCHA_PRIVATE_KEY, self.ip)
339         if not check.is_valid:
340             raise forms.ValidationError(
341                 _('You have not entered the correct words'))
342     
343     def clean(self):
344         super(LoginForm, self).clean()
345         if self.user_cache and self.user_cache.provider not in ('local', ''):
346             raise forms.ValidationError(_('Local login is not the current authentication method for this account.'))
347         return self.cleaned_data
348
349
350 class ProfileForm(forms.ModelForm):
351     """
352     Subclass of ``ModelForm`` for permiting user to edit his/her profile.
353     Most of the fields are readonly since the user is not allowed to change them.
354
355     The class defines a save method which sets ``is_verified`` to True so as the user
356     during the next login will not to be redirected to profile page.
357     """
358     renew = forms.BooleanField(label='Renew token', required=False)
359
360     class Meta:
361         model = AstakosUser
362         fields = ('email', 'first_name', 'last_name', 'auth_token',
363                   'auth_token_expires')
364
365     def __init__(self, *args, **kwargs):
366         super(ProfileForm, self).__init__(*args, **kwargs)
367         instance = getattr(self, 'instance', None)
368         ro_fields = ('email', 'auth_token', 'auth_token_expires')
369         if instance and instance.id:
370             for field in ro_fields:
371                 self.fields[field].widget.attrs['readonly'] = True
372
373     def save(self, commit=True):
374         user = super(ProfileForm, self).save(commit=False)
375         user.is_verified = True
376         if self.cleaned_data.get('renew'):
377             user.renew_token()
378         if commit:
379             user.save()
380         return user
381
382
383 class FeedbackForm(forms.Form):
384     """
385     Form for writing feedback.
386     """
387     feedback_msg = forms.CharField(widget=forms.Textarea, label=u'Message')
388     feedback_data = forms.CharField(widget=forms.HiddenInput(), label='',
389                                     required=False)
390
391
392 class SendInvitationForm(forms.Form):
393     """
394     Form for sending an invitations
395     """
396
397     email = forms.EmailField(required=True, label='Email address')
398     first_name = forms.EmailField(label='First name')
399     last_name = forms.EmailField(label='Last name')
400
401
402 class ExtendedPasswordResetForm(PasswordResetForm):
403     """
404     Extends PasswordResetForm by overriding save method:
405     passes a custom from_email in send_mail.
406
407     Since Django 1.3 this is useless since ``django.contrib.auth.views.reset_password``
408     accepts a from_email argument.
409     """
410     def clean_email(self):
411         email = super(ExtendedPasswordResetForm, self).clean_email()
412         try:
413             user = AstakosUser.objects.get(email=email, is_active=True)
414             if not user.has_usable_password():
415                 raise forms.ValidationError(
416                     _("This account has not a usable password."))
417         except AstakosUser.DoesNotExist:
418             raise forms.ValidationError(_('That e-mail address doesn\'t have an associated user account. Are you sure you\'ve registered?'))
419         return email
420
421     def save(
422         self, domain_override=None, email_template_name='registration/password_reset_email.html',
423             use_https=False, token_generator=default_token_generator, request=None):
424         """
425         Generates a one-use only link for resetting password and sends to the user.
426         """
427         for user in self.users_cache:
428             url = reverse('django.contrib.auth.views.password_reset_confirm',
429                           kwargs={'uidb36': int_to_base36(user.id),
430                                   'token': token_generator.make_token(user)
431                                   }
432                           )
433             url = urljoin(BASEURL, url)
434             t = loader.get_template(email_template_name)
435             c = {
436                 'email': user.email,
437                 'url': url,
438                 'site_name': SITENAME,
439                 'user': user,
440                 'baseurl': BASEURL,
441                 'support': DEFAULT_CONTACT_EMAIL
442             }
443             from_email = settings.SERVER_EMAIL
444             send_mail(_("Password reset on %s alpha2 testing") % SITENAME,
445                       t.render(Context(c)), from_email, [user.email])
446
447
448 class EmailChangeForm(forms.ModelForm):
449     class Meta:
450         model = EmailChange
451         fields = ('new_email_address',)
452
453     def clean_new_email_address(self):
454         addr = self.cleaned_data['new_email_address']
455         if AstakosUser.objects.filter(email__iexact=addr):
456             raise forms.ValidationError(_(u'This email address is already in use. Please supply a different email address.'))
457         return addr
458
459     def save(self, email_template_name, request, commit=True):
460         ec = super(EmailChangeForm, self).save(commit=False)
461         ec.user = request.user
462         activation_key = hashlib.sha1(
463             str(random()) + smart_str(ec.new_email_address))
464         ec.activation_key = activation_key.hexdigest()
465         if commit:
466             ec.save()
467         send_change_email(ec, request, email_template_name=email_template_name)
468
469
470 class SignApprovalTermsForm(forms.ModelForm):
471     class Meta:
472         model = AstakosUser
473         fields = ("has_signed_terms",)
474
475     def __init__(self, *args, **kwargs):
476         super(SignApprovalTermsForm, self).__init__(*args, **kwargs)
477
478     def clean_has_signed_terms(self):
479         has_signed_terms = self.cleaned_data['has_signed_terms']
480         if not has_signed_terms:
481             raise forms.ValidationError(_('You have to agree with the terms'))
482         return has_signed_terms
483
484
485 class InvitationForm(forms.ModelForm):
486     username = forms.EmailField(label=_("Email"))
487
488     def __init__(self, *args, **kwargs):
489         super(InvitationForm, self).__init__(*args, **kwargs)
490
491     class Meta:
492         model = Invitation
493         fields = ('username', 'realname')
494
495     def clean_username(self):
496         username = self.cleaned_data['username']
497         try:
498             Invitation.objects.get(username=username)
499             raise forms.ValidationError(
500                 _('There is already invitation for this email.'))
501         except Invitation.DoesNotExist:
502             pass
503         return username
504
505
506 class ExtendedPasswordChangeForm(PasswordChangeForm):
507     """
508     Extends PasswordChangeForm by enabling user
509     to optionally renew also the token.
510     """
511     renew = forms.BooleanField(label='Renew token', required=False)
512
513     def __init__(self, user, *args, **kwargs):
514         super(ExtendedPasswordChangeForm, self).__init__(user, *args, **kwargs)
515
516     def save(self, commit=True):
517         user = super(ExtendedPasswordChangeForm, self).save(commit=False)
518         if self.cleaned_data.get('renew'):
519             user.renew_token()
520         if commit:
521             user.save()
522         return user
523
524
525 class AstakosGroupCreationForm(forms.ModelForm):
526     kind = forms.ModelChoiceField(
527         queryset=GroupKind.objects.all(),
528         label="",
529         widget=forms.HiddenInput()
530     )
531     name = forms.URLField()
532     moderation_enabled = forms.BooleanField(
533         help_text="Check if you want to approve members participation manually",
534         required=False   
535     )
536     
537     class Meta:
538         model = AstakosGroup
539
540     def __init__(self, *args, **kwargs):
541         try:
542             resources = kwargs.pop('resources')
543         except KeyError:
544             resources = {}
545         super(AstakosGroupCreationForm, self).__init__(*args, **kwargs)
546         self.fields.keyOrder = ['kind', 'name', 'homepage', 'desc', 'issue_date',
547                                 'expiration_date', 'estimated_participants',
548                                 'moderation_enabled']
549         for id, r in resources.iteritems():
550             self.fields['resource_%s' % id] = forms.IntegerField(
551                 label=r,
552                 required=False,
553                 help_text=_('Leave it blank for no additional quota.')
554             )
555
556     def resources(self):
557         for name, value in self.cleaned_data.items():
558             prefix, delimiter, suffix = name.partition('resource_')
559             if suffix:
560                 # yield only those having a value
561                 if not value:
562                     continue
563                 yield (suffix, value)
564
565 class AstakosGroupUpdateForm(forms.ModelForm):
566     class Meta:
567         model = AstakosGroup
568         fields = ('homepage', 'desc')
569
570 class AddGroupMembersForm(forms.Form):
571     q = forms.CharField(max_length=800, widget=forms.Textarea, label=_('Search users'),
572                         help_text=_('Add comma separated user emails'),
573                         required=True)
574     
575     def clean(self):
576         q = self.cleaned_data.get('q') or ''
577         users = q.split(',')
578         users = list(u.strip() for u in users if u)
579         db_entries = AstakosUser.objects.filter(email__in=users)
580         unknown = list(set(users) - set(u.email for u in db_entries))
581         if unknown:
582             raise forms.ValidationError(
583                 _('Unknown users: %s' % ','.join(unknown)))
584         self.valid_users = db_entries
585         return self.cleaned_data
586     
587     def get_valid_users(self):
588         """Should be called after form cleaning"""
589         try:
590             return self.valid_users
591         except:
592             return ()
593
594
595 class AstakosGroupSearchForm(forms.Form):
596     q = forms.CharField(max_length=200, label='Search group')
597
598 class TimelineForm(forms.Form):
599     entity = forms.ModelChoiceField(
600         queryset=AstakosUser.objects.none(),
601         label="",
602         widget=forms.HiddenInput()
603     )
604     resource = forms.ModelChoiceField(
605         queryset=Resource.objects.all(),
606     )
607     start_date = forms.DateTimeField()
608     end_date = forms.DateTimeField()
609     action = forms.ChoiceField(choices=())