Statistics
| Branch: | Tag: | Revision:

root / snf-astakos-app / astakos / im / forms.py @ 8fb8d0cf

History | View | Annotate | Download (40.2 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(
327
            InvitedThirdPartyUserCreationForm, self).save(commit=False)
328
        user.set_invitation_level()
329
        user.email_verified = True
330
        if commit:
331
            user.save()
332
        return user
333

    
334

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

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

    
348

    
349
class InvitedShibbolethUserCreationForm(ShibbolethUserCreationForm,
350
                                        InvitedThirdPartyUserCreationForm):
351
    pass
352

    
353

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

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

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

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

    
379
    def clean_username(self):
380
        return self.cleaned_data['username'].lower()
381

    
382
    def clean_recaptcha_response_field(self):
383
        if 'recaptcha_challenge_field' in self.cleaned_data:
384
            self.validate_captcha()
385
        return self.cleaned_data['recaptcha_response_field']
386

    
387
    def clean_recaptcha_challenge_field(self):
388
        if 'recaptcha_response_field' in self.cleaned_data:
389
            self.validate_captcha()
390
        return self.cleaned_data['recaptcha_challenge_field']
391

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

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

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

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

    
430

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

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

    
444
    class Meta:
445
        model = AstakosUser
446
        fields = ('email', 'first_name', 'last_name')
447

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

    
457
    def clean_email(self):
458
        return self.instance.email
459

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

    
472

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

    
481

    
482
class SendInvitationForm(forms.Form):
483
    """
484
    Form for sending an invitations
485
    """
486

    
487
    email = forms.EmailField(required=True, label='Email address')
488
    first_name = forms.EmailField(label='First name')
489
    last_name = forms.EmailField(label='Last name')
490

    
491

    
492
class ExtendedPasswordResetForm(PasswordResetForm):
493
    """
494
    Extends PasswordResetForm by overriding
495

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

    
510
            provider = auth_providers.get_provider('local', user)
511
            if not user.has_usable_password():
512
                msg = provider.get_unusable_password_msg
513
                raise forms.ValidationError(mark_safe(msg))
514

    
515
            if not user.can_change_password():
516
                msg = provider.get_cannot_change_password_msg
517
                raise forms.ValidationError(mark_safe(msg))
518

    
519
        except AstakosUser.DoesNotExist:
520
            raise forms.ValidationError(_(astakos_messages.EMAIL_UNKNOWN))
521
        return email
522

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

    
550

    
551
class EmailChangeForm(forms.ModelForm):
552

    
553
    class Meta:
554
        model = EmailChange
555
        fields = ('new_email_address',)
556

    
557
    def clean_new_email_address(self):
558
        addr = self.cleaned_data['new_email_address']
559
        if reserved_verified_email(addr):
560
            raise forms.ValidationError(_(astakos_messages.EMAIL_USED))
561
        return addr
562

    
563
    def save(self, request,
564
             email_template_name='registration/email_change_email.txt',
565
             commit=True):
566
        ec = super(EmailChangeForm, self).save(commit=False)
567
        ec.user = request.user
568
        # delete pending email changes
569
        request.user.emailchanges.all().delete()
570

    
571
        activation_key = hashlib.sha1(
572
            str(random()) + smart_str(ec.new_email_address))
573
        ec.activation_key = activation_key.hexdigest()
574
        if commit:
575
            ec.save()
576
        send_change_email(ec, request, email_template_name=email_template_name)
577

    
578

    
579
class SignApprovalTermsForm(forms.ModelForm):
580

    
581
    class Meta:
582
        model = AstakosUser
583
        fields = ("has_signed_terms",)
584

    
585
    def __init__(self, *args, **kwargs):
586
        super(SignApprovalTermsForm, self).__init__(*args, **kwargs)
587

    
588
    def clean_has_signed_terms(self):
589
        has_signed_terms = self.cleaned_data['has_signed_terms']
590
        if not has_signed_terms:
591
            raise forms.ValidationError(_(astakos_messages.SIGN_TERMS))
592
        return has_signed_terms
593

    
594
    def save(self, commit=True):
595
        user = super(SignApprovalTermsForm, self).save(commit)
596
        user.date_signed_terms = datetime.now()
597
        if commit:
598
            user.save()
599
        return user
600

    
601

    
602
class InvitationForm(forms.ModelForm):
603

    
604
    username = forms.EmailField(label=_("Email"))
605

    
606
    def __init__(self, *args, **kwargs):
607
        super(InvitationForm, self).__init__(*args, **kwargs)
608

    
609
    class Meta:
610
        model = Invitation
611
        fields = ('username', 'realname')
612

    
613
    def clean_username(self):
614
        username = self.cleaned_data['username']
615
        try:
616
            Invitation.objects.get(username=username)
617
            raise forms.ValidationError(
618
                _(astakos_messages.INVITATION_EMAIL_EXISTS))
619
        except Invitation.DoesNotExist:
620
            pass
621
        return username
622

    
623

    
624
class ExtendedPasswordChangeForm(PasswordChangeForm):
625
    """
626
    Extends PasswordChangeForm by enabling user
627
    to optionally renew also the token.
628
    """
629
    if not settings.NEWPASSWD_INVALIDATE_TOKEN:
630
        renew = forms.BooleanField(
631
            label='Renew token', required=False,
632
            initial=True,
633
            help_text='Unsetting this may result in security risk.')
634

    
635
    def __init__(self, user, *args, **kwargs):
636
        self.session_key = kwargs.pop('session_key', None)
637
        super(ExtendedPasswordChangeForm, self).__init__(user, *args, **kwargs)
638

    
639
    def save(self, commit=True):
640
        try:
641
            if settings.NEWPASSWD_INVALIDATE_TOKEN or \
642
                    self.cleaned_data.get('renew'):
643
                self.user.renew_token()
644
            self.user.flush_sessions(current_key=self.session_key)
645
        except AttributeError:
646
            # if user model does has not such methods
647
            pass
648
        return super(ExtendedPasswordChangeForm, self).save(commit=commit)
649

    
650

    
651
class ExtendedSetPasswordForm(SetPasswordForm):
652
    """
653
    Extends SetPasswordForm by enabling user
654
    to optionally renew also the token.
655
    """
656
    if not settings.NEWPASSWD_INVALIDATE_TOKEN:
657
        renew = forms.BooleanField(
658
            label='Renew token',
659
            required=False,
660
            initial=True,
661
            help_text='Unsetting this may result in security risk.')
662

    
663
    def __init__(self, user, *args, **kwargs):
664
        super(ExtendedSetPasswordForm, self).__init__(user, *args, **kwargs)
665

    
666
    @transaction.commit_on_success()
667
    def save(self, commit=True):
668
        try:
669
            self.user = AstakosUser.objects.get(id=self.user.id)
670
            if settings.NEWPASSWD_INVALIDATE_TOKEN or \
671
                    self.cleaned_data.get('renew'):
672
                self.user.renew_token()
673

    
674
            provider = auth_providers.get_provider('local', self.user)
675
            if provider.get_add_policy:
676
                provider.add_to_user()
677

    
678
        except BaseException, e:
679
            logger.exception(e)
680
        return super(ExtendedSetPasswordForm, self).save(commit=commit)
681

    
682

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

    
698

    
699
app_home_label = "Homepage URL"
700
app_home_placeholder = 'myinstitution.org/myproject/'
701
app_home_help = _("""
702
        URL pointing at your project's site.
703
        e.g.: myinstitution.org/myproject/.
704
        Leave blank if there is no website.""")
705
app_home_widget = forms.TextInput(
706
    attrs={'placeholder': app_home_placeholder})
707

    
708
app_desc_label = _("Description")
709
app_desc_help = _("""
710
        Please provide a short but descriptive abstract of your
711
        project, so that anyone searching can quickly understand
712
        what this project is about.""")
713

    
714
app_comment_label = _("Comments for review (private)")
715
app_comment_help = _("""
716
        Write down any comments you may have for the reviewer
717
        of this application (e.g. background and rationale to
718
        support your request).
719
        The comments are strictly for the review process
720
        and will not be made public.""")
721

    
722
app_start_date_label = _("Start date")
723
app_start_date_help = _("""
724
        Provide a date when your need your project to be created,
725
        and members to be able to join and get resources.
726
        This date is only a hint to help prioritize reviews.""")
727

    
728
app_end_date_label = _("Termination date")
729
app_end_date_help = _("""
730
        At this date, the project will be automatically terminated
731
        and its resource grants revoked from all members. If you are
732
        not certain, it is best to start with a conservative estimation.
733
        You can always re-apply for an extension, if you need.""")
734

    
735
join_policy_label = _("Joining policy")
736
app_member_join_policy_help = _("""
737
        Select how new members are accepted into the project.""")
738
leave_policy_label = _("Leaving policy")
739
app_member_leave_policy_help = _("""
740
        Select how new members can leave the project.""")
741

    
742
max_members_label = _("Maximum member count")
743
max_members_help = _("""
744
        Specify the maximum number of members this project may have,
745
        including the owner. Beyond this number, no new members
746
        may join the project and be granted the project resources.
747
        If you are not certain, it is best to start with a conservative
748
        limit. You can always request a raise when you need it.""")
749

    
750
join_policies = presentation.PROJECT_MEMBER_JOIN_POLICIES.items()
751
leave_policies = presentation.PROJECT_MEMBER_LEAVE_POLICIES.items()
752

    
753

    
754
class ProjectApplicationForm(forms.ModelForm):
755

    
756
    name = forms.CharField(
757
        label=app_name_label,
758
        help_text=app_name_help,
759
        widget=app_name_widget,
760
        validators=[app_name_validator])
761

    
762
    homepage = forms.URLField(
763
        label=app_home_label,
764
        help_text=app_home_help,
765
        widget=app_home_widget,
766
        required=False)
767

    
768
    description = forms.CharField(
769
        label=app_desc_label,
770
        help_text=app_desc_help,
771
        widget=forms.Textarea,
772
        required=False)
773

    
774
    comments = forms.CharField(
775
        label=app_comment_label,
776
        help_text=app_comment_help,
777
        widget=forms.Textarea,
778
        required=False)
779

    
780
    start_date = forms.DateTimeField(
781
        label=app_start_date_label,
782
        help_text=app_start_date_help,
783
        required=False)
784

    
785
    end_date = forms.DateTimeField(
786
        label=app_end_date_label,
787
        help_text=app_end_date_help)
788

    
789
    member_join_policy = forms.TypedChoiceField(
790
        label=join_policy_label,
791
        help_text=app_member_join_policy_help,
792
        initial=2,
793
        coerce=int,
794
        choices=join_policies)
795

    
796
    member_leave_policy = forms.TypedChoiceField(
797
        label=leave_policy_label,
798
        help_text=app_member_leave_policy_help,
799
        coerce=int,
800
        choices=leave_policies)
801

    
802
    limit_on_members_number = forms.IntegerField(
803
        label=max_members_label,
804
        help_text=max_members_help,
805
        min_value=0,
806
        required=False)
807

    
808
    class Meta:
809
        model = ProjectApplication
810
        fields = ('name', 'homepage', 'description',
811
                  'start_date', 'end_date', 'comments',
812
                  'member_join_policy', 'member_leave_policy',
813
                  'limit_on_members_number')
814

    
815
    def __init__(self, *args, **kwargs):
816
        instance = kwargs.get('instance')
817
        self.precursor_application = instance
818
        super(ProjectApplicationForm, self).__init__(*args, **kwargs)
819
        # in case of new application remove closed join policy
820
        if not instance:
821
            policies = presentation.PROJECT_MEMBER_JOIN_POLICIES.copy()
822
            policies.pop(3)
823
            self.fields['member_join_policy'].choices = policies.iteritems()
824

    
825
    def clean_start_date(self):
826
        start_date = self.cleaned_data.get('start_date')
827
        if not self.precursor_application:
828
            today = datetime.now()
829
            today = datetime(today.year, today.month, today.day)
830
            if start_date and (start_date - today).days < 0:
831
                raise forms.ValidationError(
832
                    _(astakos_messages.INVALID_PROJECT_START_DATE))
833
        return start_date
834

    
835
    def clean_end_date(self):
836
        start_date = self.cleaned_data.get('start_date')
837
        end_date = self.cleaned_data.get('end_date')
838
        today = datetime.now()
839
        today = datetime(today.year, today.month, today.day)
840
        if end_date and (end_date - today).days < 0:
841
            raise forms.ValidationError(
842
                _(astakos_messages.INVALID_PROJECT_END_DATE))
843
        if start_date and (end_date - start_date).days <= 0:
844
            raise forms.ValidationError(
845
                _(astakos_messages.INCONSISTENT_PROJECT_DATES))
846
        return end_date
847

    
848
    def clean(self):
849
        userid = self.data.get('user', None)
850
        policies = self.resource_policies
851
        self.user = None
852
        if userid:
853
            try:
854
                self.user = AstakosUser.objects.get(id=userid)
855
            except AstakosUser.DoesNotExist:
856
                pass
857
        if not self.user:
858
            raise forms.ValidationError(_(astakos_messages.NO_APPLICANT))
859
        super(ProjectApplicationForm, self).clean()
860
        return self.cleaned_data
861

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

    
891
        ordered_keys = presentation.RESOURCES['resources_order']
892

    
893
        def resource_order(r):
894
            if r['str_repr'] in ordered_keys:
895
                return ordered_keys.index(r['str_repr'])
896
            else:
897
                return -1
898

    
899
        policies = sorted(policies, key=resource_order)
900
        return policies
901

    
902
    def cleaned_resource_policies(self):
903
        return [(d['name'], d['uplimit']) for d in self.resource_policies]
904

    
905
    def save(self, commit=True):
906
        data = dict(self.cleaned_data)
907
        data['precursor_id'] = self.instance.id
908
        is_new = self.instance.id is None
909
        data['owner'] = self.user if is_new else self.instance.owner
910
        data['resource_policies'] = self.cleaned_resource_policies()
911
        data['request_user'] = self.user
912
        submit_application(**data)
913

    
914

    
915
class ProjectSortForm(forms.Form):
916
    sorting = forms.ChoiceField(
917
        label='Sort by',
918
        choices=(('name', 'Sort by Name'),
919
                 ('issue_date', 'Sort by Issue date'),
920
                 ('start_date', 'Sort by Start Date'),
921
                 ('end_date', 'Sort by End Date'),
922
                 # ('approved_members_num', 'Sort by Participants'),
923
                 ('state', 'Sort by Status'),
924
                 ('member_join_policy__description',
925
                  'Sort by Member Join Policy'),
926
                 ('member_leave_policy__description',
927
                  'Sort by Member Leave Policy'),
928
                 ('-name', 'Sort by Name'),
929
                 ('-issue_date', 'Sort by Issue date'),
930
                 ('-start_date', 'Sort by Start Date'),
931
                 ('-end_date', 'Sort by End Date'),
932
                 # ('-approved_members_num', 'Sort by Participants'),
933
                 ('-state', 'Sort by Status'),
934
                 ('-member_join_policy__description',
935
                  'Sort by Member Join Policy'),
936
                 ('-member_leave_policy__description',
937
                  'Sort by Member Leave Policy')
938
                 ),
939
        required=True
940
    )
941

    
942

    
943
class AddProjectMembersForm(forms.Form):
944
    q = forms.CharField(
945
        widget=forms.Textarea(
946
            attrs={
947
                'placeholder':
948
                astakos_messages.ADD_PROJECT_MEMBERS_Q_PLACEHOLDER}),
949
        label=_('Add members'),
950
        help_text=_(astakos_messages.ADD_PROJECT_MEMBERS_Q_HELP),
951
        required=True,)
952

    
953
    def __init__(self, *args, **kwargs):
954
        chain_id = kwargs.pop('chain_id', None)
955
        if chain_id:
956
            self.project = Project.objects.get(id=chain_id)
957
        self.request_user = kwargs.pop('request_user', None)
958
        super(AddProjectMembersForm, self).__init__(*args, **kwargs)
959

    
960
    def clean(self):
961
        try:
962
            accept_membership_checks(self.project, self.request_user)
963
        except PermissionDenied, e:
964
            raise forms.ValidationError(e)
965

    
966
        q = self.cleaned_data.get('q') or ''
967
        users = q.split(',')
968
        users = list(u.strip() for u in users if u)
969
        db_entries = AstakosUser.objects.verified().filter(email__in=users)
970
        unknown = list(set(users) - set(u.email for u in db_entries))
971
        if unknown:
972
            raise forms.ValidationError(
973
                _(astakos_messages.UNKNOWN_USERS) % ','.join(unknown))
974
        self.valid_users = db_entries
975
        return self.cleaned_data
976

    
977
    def get_valid_users(self):
978
        """Should be called after form cleaning"""
979
        try:
980
            return self.valid_users
981
        except:
982
            return ()
983

    
984

    
985
class ProjectMembersSortForm(forms.Form):
986
    sorting = forms.ChoiceField(
987
        label='Sort by',
988
        choices=(('person__email', 'User Id'),
989
                 ('person__first_name', 'Name'),
990
                 ('acceptance_date', 'Acceptance date')
991
                 ),
992
        required=True
993
    )
994

    
995

    
996
class ProjectSearchForm(forms.Form):
997
    q = forms.CharField(max_length=200, label='Search project', required=False)
998

    
999

    
1000
class ExtendedProfileForm(ProfileForm):
1001
    """
1002
    Profile form that combines `email change` and `password change` user
1003
    actions by propagating submited data to internal EmailChangeForm
1004
    and ExtendedPasswordChangeForm objects.
1005
    """
1006

    
1007
    password_change_form = None
1008
    email_change_form = None
1009

    
1010
    password_change = False
1011
    email_change = False
1012

    
1013
    extra_forms_fields = {
1014
        'email': ['new_email_address'],
1015
        'password': ['old_password', 'new_password1', 'new_password2']
1016
    }
1017

    
1018
    fields = ('email')
1019
    change_password = forms.BooleanField(initial=False, required=False)
1020
    change_email = forms.BooleanField(initial=False, required=False)
1021

    
1022
    email_changed = False
1023
    password_changed = False
1024

    
1025
    def __init__(self, *args, **kwargs):
1026
        session_key = kwargs.get('session_key', None)
1027
        self.fields_list = [
1028
            'email',
1029
            'new_email_address',
1030
            'first_name',
1031
            'last_name',
1032
            'old_password',
1033
            'new_password1',
1034
            'new_password2',
1035
            'change_email',
1036
            'change_password',
1037
        ]
1038

    
1039
        super(ExtendedProfileForm, self).__init__(*args, **kwargs)
1040
        self.session_key = session_key
1041
        if self.instance.can_change_password():
1042
            self.password_change = True
1043
        else:
1044
            self.fields_list.remove('old_password')
1045
            self.fields_list.remove('new_password1')
1046
            self.fields_list.remove('new_password2')
1047
            self.fields_list.remove('change_password')
1048
            del self.fields['change_password']
1049

    
1050
        if settings.EMAILCHANGE_ENABLED and self.instance.can_change_email():
1051
            self.email_change = True
1052
        else:
1053
            self.fields_list.remove('new_email_address')
1054
            self.fields_list.remove('change_email')
1055
            del self.fields['change_email']
1056

    
1057
        self._init_extra_forms()
1058
        self.save_extra_forms = []
1059
        self.success_messages = []
1060
        self.fields.keyOrder = self.fields_list
1061

    
1062
    def _init_extra_form_fields(self):
1063
        if self.email_change:
1064
            self.fields.update(self.email_change_form.fields)
1065
            self.fields['new_email_address'].required = False
1066
            self.fields['email'].help_text = _(
1067
                'Change the email associated with '
1068
                'your account. This email will '
1069
                'remain active until you verify '
1070
                'your new one.')
1071

    
1072
        if self.password_change:
1073
            self.fields.update(self.password_change_form.fields)
1074
            self.fields['old_password'].required = False
1075
            self.fields['old_password'].label = _('Password')
1076
            self.fields['old_password'].help_text = _('Change your password.')
1077
            self.fields['old_password'].initial = 'password'
1078
            self.fields['new_password1'].required = False
1079
            self.fields['new_password2'].required = False
1080

    
1081
    def _update_extra_form_errors(self):
1082
        if self.cleaned_data.get('change_password'):
1083
            self.errors.update(self.password_change_form.errors)
1084
        if self.cleaned_data.get('change_email'):
1085
            self.errors.update(self.email_change_form.errors)
1086

    
1087
    def _init_extra_forms(self):
1088
        self.email_change_form = EmailChangeForm(self.data)
1089
        self.password_change_form = ExtendedPasswordChangeForm(
1090
            user=self.instance,
1091
            data=self.data, session_key=self.session_key)
1092
        self._init_extra_form_fields()
1093

    
1094
    def is_valid(self):
1095
        password, email = True, True
1096
        profile = super(ExtendedProfileForm, self).is_valid()
1097
        if profile and self.cleaned_data.get('change_password', None):
1098

    
1099
            password = self.password_change_form.is_valid()
1100
            self.save_extra_forms.append('password')
1101
        if profile and self.cleaned_data.get('change_email'):
1102
            self.fields['new_email_address'].required = True
1103
            email = self.email_change_form.is_valid()
1104
            self.save_extra_forms.append('email')
1105

    
1106
        if not password or not email:
1107
            self._update_extra_form_errors()
1108

    
1109
        return all([profile, password, email])
1110

    
1111
    def save(self, request, *args, **kwargs):
1112
        if 'email' in self.save_extra_forms:
1113
            self.email_change_form.save(request, *args, **kwargs)
1114
            self.email_changed = True
1115
        if 'password' in self.save_extra_forms:
1116
            self.password_change_form.save(*args, **kwargs)
1117
            self.password_changed = True
1118
        return super(ExtendedProfileForm, self).save(*args, **kwargs)