Statistics
| Branch: | Tag: | Revision:

root / snf-astakos-app / astakos / im / forms.py @ bebd2649

History | View | Annotate | Download (40.5 kB)

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 random import random
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, SetPasswordForm
40
from django.core.mail import send_mail, get_connection
41
from django.contrib.auth.tokens import default_token_generator
42
from django.core.urlresolvers import reverse
43
from django.utils.safestring import mark_safe
44
from django.utils.encoding import smart_str
45
from django.db import transaction
46
from django.core import validators
47
from django.core.exceptions import PermissionDenied
48

    
49
from synnefo_branding.utils import render_to_string
50
from synnefo.lib import join_urls
51
from astakos.im.models import AstakosUser, EmailChange, Invitation, Resource, \
52
    PendingThirdPartyUser, get_latest_terms, ProjectApplication, Project
53
from astakos.im import presentation
54
from astakos.im.widgets import DummyWidget, RecaptchaWidget
55
from astakos.im.functions import send_change_email, submit_application, \
56
    accept_membership_checks
57

    
58
from astakos.im.util import reserved_verified_email, model_to_dict
59
from astakos.im import auth_providers
60
from astakos.im import settings
61

    
62
import astakos.im.messages as astakos_messages
63

    
64
import logging
65
import hashlib
66
import recaptcha.client.captcha as captcha
67
import re
68

    
69
logger = logging.getLogger(__name__)
70

    
71
DOMAIN_VALUE_REGEX = re.compile(
72
    r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}$',
73
    re.IGNORECASE)
74

    
75

    
76
class StoreUserMixin(object):
77

    
78
    def store_user(self, user, request=None):
79
        """
80
        WARNING: this should be wrapped inside a transactional view/method.
81
        """
82
        user.save()
83
        self.post_store_user(user, request)
84
        return user
85

    
86
    def post_store_user(self, user, request):
87
        """
88
        Interface method for descendant backends to be able to do stuff within
89
        the transaction enabled by store_user.
90
        """
91
        pass
92

    
93

    
94
class LocalUserCreationForm(UserCreationForm, StoreUserMixin):
95
    """
96
    Extends the built in UserCreationForm in several ways:
97

98
    * Adds email, first_name, last_name, recaptcha_challenge_field,
99
    * recaptcha_response_field field.
100
    * The username field isn't visible and it is assigned a generated id.
101
    * User created is not active.
102
    """
103
    recaptcha_challenge_field = forms.CharField(widget=DummyWidget)
104
    recaptcha_response_field = forms.CharField(
105
        widget=RecaptchaWidget, label='')
106

    
107
    class Meta:
108
        model = AstakosUser
109
        fields = ("email", "first_name", "last_name",
110
                  "has_signed_terms", "has_signed_terms")
111

    
112
    def __init__(self, *args, **kwargs):
113
        """
114
        Changes the order of fields, and removes the username field.
115
        """
116
        request = kwargs.pop('request', None)
117
        provider = kwargs.pop('provider', 'local')
118

    
119
        # we only use LocalUserCreationForm for local provider
120
        if not provider == 'local':
121
            raise Exception('Invalid provider')
122

    
123
        if request:
124
            self.ip = request.META.get('REMOTE_ADDR',
125
                                       request.META.get('HTTP_X_REAL_IP',
126
                                                        None))
127

    
128
        super(LocalUserCreationForm, self).__init__(*args, **kwargs)
129
        self.fields.keyOrder = ['email', 'first_name', 'last_name',
130
                                'password1', 'password2']
131

    
132
        if settings.RECAPTCHA_ENABLED:
133
            self.fields.keyOrder.extend(['recaptcha_challenge_field',
134
                                         'recaptcha_response_field', ])
135
        if get_latest_terms():
136
            self.fields.keyOrder.append('has_signed_terms')
137

    
138
        if 'has_signed_terms' in self.fields:
139
            # Overriding field label since we need to apply a link
140
            # to the terms within the label
141
            terms_link_html = '<a href="%s" target="_blank">%s</a>' \
142
                % (reverse('latest_terms'), _("the terms"))
143
            self.fields['has_signed_terms'].label = \
144
                mark_safe("I agree with %s" % terms_link_html)
145

    
146
    def clean_email(self):
147
        email = self.cleaned_data['email']
148
        if not email:
149
            raise forms.ValidationError(_(astakos_messages.REQUIRED_FIELD))
150
        if reserved_verified_email(email):
151
            raise forms.ValidationError(_(astakos_messages.EMAIL_USED))
152
        return email
153

    
154
    def clean_has_signed_terms(self):
155
        has_signed_terms = self.cleaned_data['has_signed_terms']
156
        if not has_signed_terms:
157
            raise forms.ValidationError(_(astakos_messages.SIGN_TERMS))
158
        return has_signed_terms
159

    
160
    def clean_recaptcha_response_field(self):
161
        if 'recaptcha_challenge_field' in self.cleaned_data:
162
            self.validate_captcha()
163
        return self.cleaned_data['recaptcha_response_field']
164

    
165
    def clean_recaptcha_challenge_field(self):
166
        if 'recaptcha_response_field' in self.cleaned_data:
167
            self.validate_captcha()
168
        return self.cleaned_data['recaptcha_challenge_field']
169

    
170
    def validate_captcha(self):
171
        rcf = self.cleaned_data['recaptcha_challenge_field']
172
        rrf = self.cleaned_data['recaptcha_response_field']
173
        check = captcha.submit(
174
            rcf, rrf, settings.RECAPTCHA_PRIVATE_KEY, self.ip)
175
        if not check.is_valid:
176
            raise forms.ValidationError(_(
177
                astakos_messages.CAPTCHA_VALIDATION_ERR))
178

    
179
    def post_store_user(self, user, request=None):
180
        """
181
        Interface method for descendant backends to be able to do stuff within
182
        the transaction enabled by store_user.
183
        """
184
        user.add_auth_provider('local', auth_backend='astakos')
185
        user.set_password(self.cleaned_data['password1'])
186

    
187
    def save(self, commit=True):
188
        """
189
        Saves the email, first_name and last_name properties, after the normal
190
        save behavior is complete.
191
        """
192
        user = super(LocalUserCreationForm, self).save(commit=False)
193
        user.date_signed_terms = datetime.now()
194
        user.renew_token()
195
        if commit:
196
            user.save()
197
            logger.info('Created user %s', user.log_display)
198
        return user
199

    
200

    
201
class InvitedLocalUserCreationForm(LocalUserCreationForm):
202
    """
203
    Extends the LocalUserCreationForm: email is readonly.
204
    """
205
    class Meta:
206
        model = AstakosUser
207
        fields = ("email", "first_name", "last_name", "has_signed_terms")
208

    
209
    def __init__(self, *args, **kwargs):
210
        """
211
        Changes the order of fields, and removes the username field.
212
        """
213
        super(InvitedLocalUserCreationForm, self).__init__(*args, **kwargs)
214

    
215
        #set readonly form fields
216
        ro = ('email', 'username',)
217
        for f in ro:
218
            self.fields[f].widget.attrs['readonly'] = True
219

    
220
    def save(self, commit=True):
221
        user = super(InvitedLocalUserCreationForm, self).save(commit=False)
222
        user.set_invitations_level()
223
        user.email_verified = True
224
        if commit:
225
            user.save()
226
        return user
227

    
228

    
229
class ThirdPartyUserCreationForm(forms.ModelForm, StoreUserMixin):
230
    email = forms.EmailField(
231
        label='Contact email',
232
        help_text='This is needed for contact purposes. '
233
        'It doesn&#39;t need to be the same with the one you '
234
        'provided to login previously. '
235
    )
236

    
237
    class Meta:
238
        model = AstakosUser
239
        fields = ['email', 'first_name', 'last_name', 'has_signed_terms']
240

    
241
    def __init__(self, *args, **kwargs):
242
        """
243
        Changes the order of fields, and removes the username field.
244
        """
245

    
246
        self.provider = kwargs.pop('provider', None)
247
        if not self.provider or self.provider == 'local':
248
            raise Exception('Invalid provider, %r' % self.provider)
249

    
250
        # ThirdPartyUserCreationForm should always get instantiated with
251
        # a third_party_token value
252
        self.third_party_token = kwargs.pop('third_party_token', None)
253
        if not self.third_party_token:
254
            raise Exception('ThirdPartyUserCreationForm'
255
                            ' requires third_party_token')
256

    
257
        super(ThirdPartyUserCreationForm, self).__init__(*args, **kwargs)
258

    
259
        if not get_latest_terms():
260
            del self.fields['has_signed_terms']
261

    
262
        if 'has_signed_terms' in self.fields:
263
            # Overriding field label since we need to apply a link
264
            # to the terms within the label
265
            terms_link_html = '<a href="%s" target="_blank">%s</a>' \
266
                % (reverse('latest_terms'), _("the terms"))
267
            self.fields['has_signed_terms'].label = \
268
                mark_safe("I agree with %s" % terms_link_html)
269

    
270
    def clean_email(self):
271
        email = self.cleaned_data['email']
272
        if not email:
273
            raise forms.ValidationError(_(astakos_messages.REQUIRED_FIELD))
274
        if reserved_verified_email(email):
275
            provider_id = self.provider
276
            provider = auth_providers.get_provider(provider_id)
277
            extra_message = provider.get_add_to_existing_account_msg
278

    
279
            raise forms.ValidationError(mark_safe(
280
                _(astakos_messages.EMAIL_USED) + ' ' + extra_message))
281
        return email
282

    
283
    def clean_has_signed_terms(self):
284
        has_signed_terms = self.cleaned_data['has_signed_terms']
285
        if not has_signed_terms:
286
            raise forms.ValidationError(_(astakos_messages.SIGN_TERMS))
287
        return has_signed_terms
288

    
289
    def _get_pending_user(self):
290
        return PendingThirdPartyUser.objects.get(token=self.third_party_token)
291

    
292
    def post_store_user(self, user, request=None):
293
        pending = self._get_pending_user()
294
        provider = pending.get_provider(user)
295
        provider.add_to_user()
296
        pending.delete()
297

    
298
    def save(self, commit=True):
299
        user = super(ThirdPartyUserCreationForm, self).save(commit=False)
300
        user.set_unusable_password()
301
        user.renew_token()
302
        user.date_signed_terms = datetime.now()
303
        if commit:
304
            user.save()
305
            logger.info('Created user %s' % user.log_display)
306
        return user
307

    
308

    
309
class InvitedThirdPartyUserCreationForm(ThirdPartyUserCreationForm):
310
    """
311
    Extends the ThirdPartyUserCreationForm: email is readonly.
312
    """
313
    def __init__(self, *args, **kwargs):
314
        """
315
        Changes the order of fields, and removes the username field.
316
        """
317
        super(
318
            InvitedThirdPartyUserCreationForm, self).__init__(*args, **kwargs)
319

    
320
        #set readonly form fields
321
        ro = ('email',)
322
        for f in ro:
323
            self.fields[f].widget.attrs['readonly'] = True
324

    
325
    def save(self, commit=True):
326
        user = super(InvitedThirdPartyUserCreationForm, self).save(commit=False)
327
        user.set_invitation_level()
328
        user.email_verified = True
329
        if commit:
330
            user.save()
331
        return user
332

    
333

    
334
class ShibbolethUserCreationForm(ThirdPartyUserCreationForm):
335
    additional_email = forms.CharField(
336
        widget=forms.HiddenInput(), label='', required=False)
337

    
338
    def __init__(self, *args, **kwargs):
339
        super(ShibbolethUserCreationForm, self).__init__(*args, **kwargs)
340
        # copy email value to additional_mail in case user will change it
341
        name = 'email'
342
        field = self.fields[name]
343
        self.initial['additional_email'] = self.initial.get(name, field.initial)
344
        self.initial['email'] = None
345

    
346

    
347
class InvitedShibbolethUserCreationForm(ShibbolethUserCreationForm,
348
                                        InvitedThirdPartyUserCreationForm):
349
    pass
350

    
351

    
352
class LoginForm(AuthenticationForm):
353
    username = forms.EmailField(label=_("Email"))
354
    recaptcha_challenge_field = forms.CharField(widget=DummyWidget)
355
    recaptcha_response_field = forms.CharField(
356
        widget=RecaptchaWidget, label='')
357

    
358
    def __init__(self, *args, **kwargs):
359
        was_limited = kwargs.get('was_limited', False)
360
        request = kwargs.get('request', None)
361
        if request:
362
            self.ip = request.META.get('REMOTE_ADDR',
363
                                       request.META.get('HTTP_X_REAL_IP', None))
364

    
365
        t = ('request', 'was_limited')
366
        for elem in t:
367
            if elem in kwargs.keys():
368
                kwargs.pop(elem)
369
        super(LoginForm, self).__init__(*args, **kwargs)
370

    
371
        self.fields.keyOrder = ['username', 'password']
372
        if was_limited and settings.RECAPTCHA_ENABLED:
373
            self.fields.keyOrder.extend(['recaptcha_challenge_field',
374
                                         'recaptcha_response_field', ])
375

    
376
    def clean_username(self):
377
        return self.cleaned_data['username'].lower()
378

    
379
    def clean_recaptcha_response_field(self):
380
        if 'recaptcha_challenge_field' in self.cleaned_data:
381
            self.validate_captcha()
382
        return self.cleaned_data['recaptcha_response_field']
383

    
384
    def clean_recaptcha_challenge_field(self):
385
        if 'recaptcha_response_field' in self.cleaned_data:
386
            self.validate_captcha()
387
        return self.cleaned_data['recaptcha_challenge_field']
388

    
389
    def validate_captcha(self):
390
        rcf = self.cleaned_data['recaptcha_challenge_field']
391
        rrf = self.cleaned_data['recaptcha_response_field']
392
        check = captcha.submit(
393
            rcf, rrf, settings.RECAPTCHA_PRIVATE_KEY, self.ip)
394
        if not check.is_valid:
395
            raise forms.ValidationError(_(
396
                astakos_messages.CAPTCHA_VALIDATION_ERR))
397

    
398
    def clean(self):
399
        """
400
        Override default behavior in order to check user's activation later
401
        """
402
        username = self.cleaned_data.get('username')
403

    
404
        if username:
405
            try:
406
                user = AstakosUser.objects.get_by_identifier(username)
407
                if not user.has_auth_provider('local'):
408
                    provider = auth_providers.get_provider('local', user)
409
                    msg = provider.get_login_disabled_msg
410
                    raise forms.ValidationError(mark_safe(msg))
411
            except AstakosUser.DoesNotExist:
412
                pass
413

    
414
        try:
415
            super(LoginForm, self).clean()
416
        except forms.ValidationError, e:
417
            if self.user_cache is None:
418
                raise
419
            if not self.user_cache.is_active:
420
                msg = self.user_cache.get_inactive_message('local')
421
                raise forms.ValidationError(msg)
422
            if self.request:
423
                if not self.request.session.test_cookie_worked():
424
                    raise
425
        return self.cleaned_data
426

    
427

    
428
class ProfileForm(forms.ModelForm):
429
    """
430
    Subclass of ``ModelForm`` for permiting user to edit his/her profile.
431
    Most of the fields are readonly since the user is not allowed to change
432
    them.
433

434
    The class defines a save method which sets ``is_verified`` to True so as the
435
    user during the next login will not to be redirected to profile page.
436
    """
437
    email = forms.EmailField(label='E-mail address', help_text='E-mail address')
438
    renew = forms.BooleanField(label='Renew token', required=False)
439

    
440
    class Meta:
441
        model = AstakosUser
442
        fields = ('email', 'first_name', 'last_name')
443

    
444
    def __init__(self, *args, **kwargs):
445
        self.session_key = kwargs.pop('session_key', None)
446
        super(ProfileForm, self).__init__(*args, **kwargs)
447
        instance = getattr(self, 'instance', None)
448
        ro_fields = ('email',)
449
        if instance and instance.id:
450
            for field in ro_fields:
451
                self.fields[field].widget.attrs['readonly'] = True
452

    
453
    def clean_email(self):
454
        return self.instance.email
455

    
456
    def save(self, commit=True):
457
        user = super(ProfileForm, self).save(commit=False)
458
        user.is_verified = True
459
        if self.cleaned_data.get('renew'):
460
            user.renew_token(
461
                flush_sessions=True,
462
                current_key=self.session_key
463
            )
464
        if commit:
465
            user.save()
466
        return user
467

    
468

    
469

    
470
class FeedbackForm(forms.Form):
471
    """
472
    Form for writing feedback.
473
    """
474
    feedback_msg = forms.CharField(widget=forms.Textarea, label=u'Message')
475
    feedback_data = forms.CharField(widget=forms.HiddenInput(), label='',
476
                                    required=False)
477

    
478

    
479
class SendInvitationForm(forms.Form):
480
    """
481
    Form for sending an invitations
482
    """
483

    
484
    email = forms.EmailField(required=True, label='Email address')
485
    first_name = forms.EmailField(label='First name')
486
    last_name = forms.EmailField(label='Last name')
487

    
488

    
489
class ExtendedPasswordResetForm(PasswordResetForm):
490
    """
491
    Extends PasswordResetForm by overriding
492

493
    save method: to pass a custom from_email in send_mail.
494
    clean_email: to handle local auth provider checks
495
    """
496
    def clean_email(self):
497
        # we override the default django auth clean_email to provide more
498
        # detailed messages in case of inactive users
499
        email = self.cleaned_data['email']
500
        try:
501
            user = AstakosUser.objects.get_by_identifier(email)
502
            self.users_cache = [user]
503
            if not user.is_active:
504
                msg = mark_safe(user.get_inactive_message('local'))
505
                raise forms.ValidationError(msg)
506

    
507
            provider = auth_providers.get_provider('local', user)
508
            if not user.has_usable_password():
509
                msg = provider.get_unusable_password_msg
510
                raise forms.ValidationError(mark_safe(msg))
511

    
512
            if not user.can_change_password():
513
                msg = provider.get_cannot_change_password_msg
514
                raise forms.ValidationError(mark_safe(msg))
515

    
516
        except AstakosUser.DoesNotExist:
517
            raise forms.ValidationError(_(astakos_messages.EMAIL_UNKNOWN))
518
        return email
519

    
520
    def save(
521
        self, domain_override=None, email_template_name='registration/password_reset_email.html',
522
            use_https=False, token_generator=default_token_generator, request=None):
523
        """
524
        Generates a one-use only link for resetting password and sends to the user.
525
        """
526
        for user in self.users_cache:
527
            url = user.astakosuser.get_password_reset_url(token_generator)
528
            url = join_urls(settings.BASE_URL, url)
529
            c = {
530
                'email': user.email,
531
                'url': url,
532
                'site_name': settings.SITENAME,
533
                'user': user,
534
                'baseurl': settings.BASE_URL,
535
                'support': settings.CONTACT_EMAIL
536
            }
537
            message = render_to_string(email_template_name, c)
538
            from_email = settings.SERVER_EMAIL
539
            send_mail(_(astakos_messages.PASSWORD_RESET_EMAIL_SUBJECT),
540
                      message,
541
                      from_email,
542
                      [user.email],
543
                      connection=get_connection())
544

    
545

    
546
class EmailChangeForm(forms.ModelForm):
547

    
548
    class Meta:
549
        model = EmailChange
550
        fields = ('new_email_address',)
551

    
552
    def clean_new_email_address(self):
553
        addr = self.cleaned_data['new_email_address']
554
        if reserved_verified_email(addr):
555
            raise forms.ValidationError(_(astakos_messages.EMAIL_USED))
556
        return addr
557

    
558
    def save(self, request, email_template_name='registration/email_change_email.txt', commit=True):
559
        ec = super(EmailChangeForm, self).save(commit=False)
560
        ec.user = request.user
561
        # delete pending email changes
562
        request.user.emailchanges.all().delete()
563

    
564
        activation_key = hashlib.sha1(
565
            str(random()) + smart_str(ec.new_email_address))
566
        ec.activation_key = activation_key.hexdigest()
567
        if commit:
568
            ec.save()
569
        send_change_email(ec, request, email_template_name=email_template_name)
570

    
571

    
572
class SignApprovalTermsForm(forms.ModelForm):
573

    
574
    class Meta:
575
        model = AstakosUser
576
        fields = ("has_signed_terms",)
577

    
578
    def __init__(self, *args, **kwargs):
579
        super(SignApprovalTermsForm, self).__init__(*args, **kwargs)
580

    
581
    def clean_has_signed_terms(self):
582
        has_signed_terms = self.cleaned_data['has_signed_terms']
583
        if not has_signed_terms:
584
            raise forms.ValidationError(_(astakos_messages.SIGN_TERMS))
585
        return has_signed_terms
586

    
587
    def save(self, commit=True):
588
        user = super(SignApprovalTermsForm, self).save(commit)
589
        user.date_signed_terms = datetime.now()
590
        if commit:
591
            user.save()
592
        return user
593

    
594

    
595
class InvitationForm(forms.ModelForm):
596

    
597
    username = forms.EmailField(label=_("Email"))
598

    
599
    def __init__(self, *args, **kwargs):
600
        super(InvitationForm, self).__init__(*args, **kwargs)
601

    
602
    class Meta:
603
        model = Invitation
604
        fields = ('username', 'realname')
605

    
606
    def clean_username(self):
607
        username = self.cleaned_data['username']
608
        try:
609
            Invitation.objects.get(username=username)
610
            raise forms.ValidationError(_(astakos_messages.INVITATION_EMAIL_EXISTS))
611
        except Invitation.DoesNotExist:
612
            pass
613
        return username
614

    
615

    
616
class ExtendedPasswordChangeForm(PasswordChangeForm):
617
    """
618
    Extends PasswordChangeForm by enabling user
619
    to optionally renew also the token.
620
    """
621
    if not settings.NEWPASSWD_INVALIDATE_TOKEN:
622
        renew = forms.BooleanField(label='Renew token', required=False,
623
                                   initial=True,
624
                                   help_text='Unsetting this may result in security risk.')
625

    
626
    def __init__(self, user, *args, **kwargs):
627
        self.session_key = kwargs.pop('session_key', None)
628
        super(ExtendedPasswordChangeForm, self).__init__(user, *args, **kwargs)
629

    
630
    def save(self, commit=True):
631
        try:
632
            if settings.NEWPASSWD_INVALIDATE_TOKEN or \
633
                    self.cleaned_data.get('renew'):
634
                self.user.renew_token()
635
            self.user.flush_sessions(current_key=self.session_key)
636
        except AttributeError:
637
            # if user model does has not such methods
638
            pass
639
        return super(ExtendedPasswordChangeForm, self).save(commit=commit)
640

    
641
class ExtendedSetPasswordForm(SetPasswordForm):
642
    """
643
    Extends SetPasswordForm by enabling user
644
    to optionally renew also the token.
645
    """
646
    if not settings.NEWPASSWD_INVALIDATE_TOKEN:
647
        renew = forms.BooleanField(
648
            label='Renew token',
649
            required=False,
650
            initial=True,
651
            help_text='Unsetting this may result in security risk.')
652

    
653
    def __init__(self, user, *args, **kwargs):
654
        super(ExtendedSetPasswordForm, self).__init__(user, *args, **kwargs)
655

    
656
    @transaction.commit_on_success()
657
    def save(self, commit=True):
658
        try:
659
            self.user = AstakosUser.objects.get(id=self.user.id)
660
            if settings.NEWPASSWD_INVALIDATE_TOKEN or \
661
                    self.cleaned_data.get('renew'):
662
                self.user.renew_token()
663

    
664
            provider = auth_providers.get_provider('local', self.user)
665
            if provider.get_add_policy:
666
                provider.add_to_user()
667

    
668
        except BaseException, e:
669
            logger.exception(e)
670
        return super(ExtendedSetPasswordForm, self).save(commit=commit)
671

    
672

    
673

    
674

    
675
app_name_label       =  "Project name"
676
app_name_placeholder = _("myproject.mylab.ntua.gr")
677
app_name_validator   =  validators.RegexValidator(
678
                            DOMAIN_VALUE_REGEX,
679
                            _(astakos_messages.DOMAIN_VALUE_ERR),
680
                            'invalid')
681
app_name_help        =  _("""
682
        The project's name should be in a domain format.
683
        The domain shouldn't neccessarily exist in the real
684
        world but is helpful to imply a structure.
685
        e.g.: myproject.mylab.ntua.gr or
686
        myservice.myteam.myorganization""")
687
app_name_widget      =  forms.TextInput(
688
                            attrs={'placeholder': app_name_placeholder})
689

    
690

    
691
app_home_label       =  "Homepage URL"
692
app_home_placeholder =  'myinstitution.org/myproject/'
693
app_home_help        =  _("""
694
        URL pointing at your project's site.
695
        e.g.: myinstitution.org/myproject/.
696
        Leave blank if there is no website.""")
697
app_home_widget      =  forms.TextInput(
698
                            attrs={'placeholder': app_home_placeholder})
699

    
700
app_desc_label       =  _("Description")
701
app_desc_help        =  _("""
702
        Please provide a short but descriptive abstract of your
703
        project, so that anyone searching can quickly understand
704
        what this project is about.""")
705

    
706
app_comment_label    =  _("Comments for review (private)")
707
app_comment_help     =  _("""
708
        Write down any comments you may have for the reviewer
709
        of this application (e.g. background and rationale to
710
        support your request).
711
        The comments are strictly for the review process
712
        and will not be made public.""")
713

    
714
app_start_date_label =  _("Start date")
715
app_start_date_help  =  _("""
716
        Provide a date when your need your project to be created,
717
        and members to be able to join and get resources.
718
        This date is only a hint to help prioritize reviews.""")
719

    
720
app_end_date_label   =  _("Termination date")
721
app_end_date_help    =  _("""
722
        At this date, the project will be automatically terminated
723
        and its resource grants revoked from all members. If you are
724
        not certain, it is best to start with a conservative estimation.
725
        You can always re-apply for an extension, if you need.""")
726

    
727
join_policy_label    =  _("Joining policy")
728
app_member_join_policy_help    =  _("""
729
        Select how new members are accepted into the project.""")
730
leave_policy_label   =  _("Leaving policy")
731
app_member_leave_policy_help    =  _("""
732
        Select how new members can leave the project.""")
733

    
734
max_members_label    =  _("Maximum member count")
735
max_members_help     =  _("""
736
        Specify the maximum number of members this project may have,
737
        including the owner. Beyond this number, no new members
738
        may join the project and be granted the project resources.
739
        If you are not certain, it is best to start with a conservative
740
        limit. You can always request a raise when you need it.""")
741

    
742
join_policies = presentation.PROJECT_MEMBER_JOIN_POLICIES.items()
743
leave_policies = presentation.PROJECT_MEMBER_LEAVE_POLICIES.items()
744

    
745
class ProjectApplicationForm(forms.ModelForm):
746

    
747
    name = forms.CharField(
748
        label     = app_name_label,
749
        help_text = app_name_help,
750
        widget    = app_name_widget,
751
        validators = [app_name_validator])
752

    
753
    homepage = forms.URLField(
754
        label     = app_home_label,
755
        help_text = app_home_help,
756
        widget    = app_home_widget,
757
        required  = False)
758

    
759
    description = forms.CharField(
760
        label     = app_desc_label,
761
        help_text = app_desc_help,
762
        widget    = forms.Textarea,
763
        required  = False)
764

    
765
    comments = forms.CharField(
766
        label     = app_comment_label,
767
        help_text = app_comment_help,
768
        widget    = forms.Textarea,
769
        required  = False)
770

    
771
    start_date = forms.DateTimeField(
772
        label     = app_start_date_label,
773
        help_text = app_start_date_help,
774
        required  = False)
775

    
776
    end_date = forms.DateTimeField(
777
        label     = app_end_date_label,
778
        help_text = app_end_date_help)
779

    
780
    member_join_policy  = forms.TypedChoiceField(
781
        label     = join_policy_label,
782
        help_text = app_member_join_policy_help,
783
        initial   = 2,
784
        coerce    = int,
785
        choices   = join_policies)
786

    
787
    member_leave_policy = forms.TypedChoiceField(
788
        label     = leave_policy_label,
789
        help_text = app_member_leave_policy_help,
790
        coerce    = int,
791
        choices   = leave_policies)
792

    
793
    limit_on_members_number = forms.IntegerField(
794
        label     = max_members_label,
795
        help_text = max_members_help,
796
        min_value = 0,
797
        required  = False)
798

    
799
    class Meta:
800
        model = ProjectApplication
801
        fields = ( 'name', 'homepage', 'description',
802
                    'start_date', 'end_date', 'comments',
803
                    'member_join_policy', 'member_leave_policy',
804
                    'limit_on_members_number')
805

    
806
    def __init__(self, *args, **kwargs):
807
        instance = kwargs.get('instance')
808
        self.precursor_application = instance
809
        super(ProjectApplicationForm, self).__init__(*args, **kwargs)
810
        # in case of new application remove closed join policy
811
        if not instance:
812
            policies = presentation.PROJECT_MEMBER_JOIN_POLICIES.copy()
813
            policies.pop(3)
814
            self.fields['member_join_policy'].choices = policies.iteritems()
815

    
816
    def clean_start_date(self):
817
        start_date = self.cleaned_data.get('start_date')
818
        if not self.precursor_application:
819
            today = datetime.now()
820
            today = datetime(today.year, today.month, today.day)
821
            if start_date and (start_date - today).days < 0:
822
                raise forms.ValidationError(
823
                _(astakos_messages.INVALID_PROJECT_START_DATE))
824
        return start_date
825

    
826
    def clean_end_date(self):
827
        start_date = self.cleaned_data.get('start_date')
828
        end_date = self.cleaned_data.get('end_date')
829
        today = datetime.now()
830
        today = datetime(today.year, today.month, today.day)
831
        if end_date and (end_date - today).days < 0:
832
            raise forms.ValidationError(
833
                _(astakos_messages.INVALID_PROJECT_END_DATE))
834
        if start_date and (end_date - start_date).days <= 0:
835
            raise forms.ValidationError(
836
                _(astakos_messages.INCONSISTENT_PROJECT_DATES))
837
        return end_date
838

    
839
    def clean(self):
840
        userid = self.data.get('user', None)
841
        policies = self.resource_policies
842
        self.user = None
843
        if userid:
844
            try:
845
                self.user = AstakosUser.objects.get(id=userid)
846
            except AstakosUser.DoesNotExist:
847
                pass
848
        if not self.user:
849
            raise forms.ValidationError(_(astakos_messages.NO_APPLICANT))
850
        super(ProjectApplicationForm, self).clean()
851
        return self.cleaned_data
852

    
853
    @property
854
    def resource_policies(self):
855
        policies = []
856
        append = policies.append
857
        for name, value in self.data.iteritems():
858
            if not value:
859
                continue
860
            uplimit = value
861
            if name.endswith('_uplimit'):
862
                subs = name.split('_uplimit')
863
                prefix, suffix = subs
864
                try:
865
                    resource = Resource.objects.get(name=prefix)
866
                except Resource.DoesNotExist:
867
                    raise forms.ValidationError("Resource %s does not exist" %
868
                                                resource.name)
869
                # keep only resource limits for selected resource groups
870
                if self.data.get(
871
                    'is_selected_%s' % resource.group, "0"
872
                 ) == "1":
873
                    if not resource.allow_in_projects:
874
                        raise forms.ValidationError("Invalid resource %s" %
875
                                                    resource.name)
876
                    d = model_to_dict(resource)
877
                    if uplimit:
878
                        d.update(dict(resource=prefix, uplimit=uplimit))
879
                    else:
880
                        d.update(dict(resource=prefix, uplimit=None))
881
                    append(d)
882

    
883
        ordered_keys = presentation.RESOURCES['resources_order']
884
        def resource_order(r):
885
            if r['str_repr'] in ordered_keys:
886
                return ordered_keys.index(r['str_repr'])
887
            else:
888
                return -1
889

    
890
        policies = sorted(policies, key=resource_order)
891
        return policies
892

    
893
    def cleaned_resource_policies(self):
894
        return [(d['name'], d['uplimit']) for d in self.resource_policies]
895

    
896
    def save(self, commit=True):
897
        data = dict(self.cleaned_data)
898
        data['precursor_id'] = self.instance.id
899
        is_new = self.instance.id is None
900
        data['owner'] = self.user if is_new else self.instance.owner
901
        data['resource_policies'] = self.cleaned_resource_policies()
902
        data['request_user'] = self.user
903
        submit_application(**data)
904

    
905

    
906
class ProjectSortForm(forms.Form):
907
    sorting = forms.ChoiceField(
908
        label='Sort by',
909
        choices=(('name', 'Sort by Name'),
910
                 ('issue_date', 'Sort by Issue date'),
911
                 ('start_date', 'Sort by Start Date'),
912
                 ('end_date', 'Sort by End Date'),
913
#                  ('approved_members_num', 'Sort by Participants'),
914
                 ('state', 'Sort by Status'),
915
                 ('member_join_policy__description', 'Sort by Member Join Policy'),
916
                 ('member_leave_policy__description', 'Sort by Member Leave Policy'),
917
                 ('-name', 'Sort by Name'),
918
                 ('-issue_date', 'Sort by Issue date'),
919
                 ('-start_date', 'Sort by Start Date'),
920
                 ('-end_date', 'Sort by End Date'),
921
#                  ('-approved_members_num', 'Sort by Participants'),
922
                 ('-state', 'Sort by Status'),
923
                 ('-member_join_policy__description', 'Sort by Member Join Policy'),
924
                 ('-member_leave_policy__description', 'Sort by Member Leave Policy')
925
        ),
926
        required=True
927
    )
928

    
929
class AddProjectMembersForm(forms.Form):
930
    q = forms.CharField(
931
        widget=forms.Textarea(attrs={
932
            'placeholder': astakos_messages.ADD_PROJECT_MEMBERS_Q_PLACEHOLDER}
933
            ),
934
        label=_('Add members'),
935
        help_text=_(astakos_messages.ADD_PROJECT_MEMBERS_Q_HELP),
936
        required=True,)
937

    
938
    def __init__(self, *args, **kwargs):
939
        chain_id = kwargs.pop('chain_id', None)
940
        if chain_id:
941
            self.project = Project.objects.get(id=chain_id)
942
        self.request_user = kwargs.pop('request_user', None)
943
        super(AddProjectMembersForm, self).__init__(*args, **kwargs)
944

    
945
    def clean(self):
946
        try:
947
            accept_membership_checks(self.project, self.request_user)
948
        except PermissionDenied, e:
949
            raise forms.ValidationError(e)
950

    
951
        q = self.cleaned_data.get('q') or ''
952
        users = q.split(',')
953
        users = list(u.strip() for u in users if u)
954
        db_entries = AstakosUser.objects.verified().filter(email__in=users)
955
        unknown = list(set(users) - set(u.email for u in db_entries))
956
        if unknown:
957
            raise forms.ValidationError(
958
                _(astakos_messages.UNKNOWN_USERS) % ','.join(unknown))
959
        self.valid_users = db_entries
960
        return self.cleaned_data
961

    
962
    def get_valid_users(self):
963
        """Should be called after form cleaning"""
964
        try:
965
            return self.valid_users
966
        except:
967
            return ()
968

    
969
class ProjectMembersSortForm(forms.Form):
970
    sorting = forms.ChoiceField(
971
        label='Sort by',
972
        choices=(('person__email', 'User Id'),
973
                 ('person__first_name', 'Name'),
974
                 ('acceptance_date', 'Acceptance date')
975
        ),
976
        required=True
977
    )
978

    
979

    
980
class ProjectSearchForm(forms.Form):
981
    q = forms.CharField(max_length=200, label='Search project', required=False)
982

    
983

    
984
class ExtendedProfileForm(ProfileForm):
985
    """
986
    Profile form that combines `email change` and `password change` user
987
    actions by propagating submited data to internal EmailChangeForm
988
    and ExtendedPasswordChangeForm objects.
989
    """
990

    
991
    password_change_form = None
992
    email_change_form = None
993

    
994
    password_change = False
995
    email_change = False
996

    
997
    extra_forms_fields = {
998
        'email': ['new_email_address'],
999
        'password': ['old_password', 'new_password1', 'new_password2']
1000
    }
1001

    
1002
    fields = ('email')
1003
    change_password = forms.BooleanField(initial=False, required=False)
1004
    change_email = forms.BooleanField(initial=False, required=False)
1005

    
1006
    email_changed = False
1007
    password_changed = False
1008

    
1009
    def __init__(self, *args, **kwargs):
1010
        session_key = kwargs.get('session_key', None)
1011
        self.fields_list = [
1012
                'email',
1013
                'new_email_address',
1014
                'first_name',
1015
                'last_name',
1016
                'old_password',
1017
                'new_password1',
1018
                'new_password2',
1019
                'change_email',
1020
                'change_password',
1021
        ]
1022

    
1023
        super(ExtendedProfileForm, self).__init__(*args, **kwargs)
1024
        self.session_key = session_key
1025
        if self.instance.can_change_password():
1026
            self.password_change = True
1027
        else:
1028
            self.fields_list.remove('old_password')
1029
            self.fields_list.remove('new_password1')
1030
            self.fields_list.remove('new_password2')
1031
            self.fields_list.remove('change_password')
1032
            del self.fields['change_password']
1033

    
1034
        if settings.EMAILCHANGE_ENABLED and self.instance.can_change_email():
1035
            self.email_change = True
1036
        else:
1037
            self.fields_list.remove('new_email_address')
1038
            self.fields_list.remove('change_email')
1039
            del self.fields['change_email']
1040

    
1041
        self._init_extra_forms()
1042
        self.save_extra_forms = []
1043
        self.success_messages = []
1044
        self.fields.keyOrder = self.fields_list
1045

    
1046

    
1047
    def _init_extra_form_fields(self):
1048
        if self.email_change:
1049
            self.fields.update(self.email_change_form.fields)
1050
            self.fields['new_email_address'].required = False
1051
            self.fields['email'].help_text = _('Change the email associated with '
1052
                                               'your account. This email will '
1053
                                               'remain active until you verify '
1054
                                               'your new one.')
1055

    
1056
        if self.password_change:
1057
            self.fields.update(self.password_change_form.fields)
1058
            self.fields['old_password'].required = False
1059
            self.fields['old_password'].label = _('Password')
1060
            self.fields['old_password'].help_text = _('Change your password.')
1061
            self.fields['old_password'].initial = 'password'
1062
            self.fields['new_password1'].required = False
1063
            self.fields['new_password2'].required = False
1064

    
1065
    def _update_extra_form_errors(self):
1066
        if self.cleaned_data.get('change_password'):
1067
            self.errors.update(self.password_change_form.errors)
1068
        if self.cleaned_data.get('change_email'):
1069
            self.errors.update(self.email_change_form.errors)
1070

    
1071
    def _init_extra_forms(self):
1072
        self.email_change_form = EmailChangeForm(self.data)
1073
        self.password_change_form = ExtendedPasswordChangeForm(user=self.instance,
1074
                                   data=self.data, session_key=self.session_key)
1075
        self._init_extra_form_fields()
1076

    
1077
    def is_valid(self):
1078
        password, email = True, True
1079
        profile = super(ExtendedProfileForm, self).is_valid()
1080
        if profile and self.cleaned_data.get('change_password', None):
1081

    
1082
            password = self.password_change_form.is_valid()
1083
            self.save_extra_forms.append('password')
1084
        if profile and self.cleaned_data.get('change_email'):
1085
            self.fields['new_email_address'].required = True
1086
            email = self.email_change_form.is_valid()
1087
            self.save_extra_forms.append('email')
1088

    
1089
        if not password or not email:
1090
            self._update_extra_form_errors()
1091

    
1092
        return all([profile, password, email])
1093

    
1094
    def save(self, request, *args, **kwargs):
1095
        if 'email' in self.save_extra_forms:
1096
            self.email_change_form.save(request, *args, **kwargs)
1097
            self.email_changed = True
1098
        if 'password' in self.save_extra_forms:
1099
            self.password_change_form.save(*args, **kwargs)
1100
            self.password_changed = True
1101
        return super(ExtendedProfileForm, self).save(*args, **kwargs)
1102