Statistics
| Branch: | Tag: | Revision:

root / snf-astakos-app / astakos / im / forms.py @ 9efd0075

History | View | Annotate | Download (42.3 kB)

1
# Copyright 2011, 2012, 2013 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
import re
34
import synnefo.util.date as date_util
35

    
36
from random import random
37
from datetime import datetime
38

    
39
from django import forms
40
from django.utils.translation import ugettext as _
41
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm, \
42
    PasswordResetForm, PasswordChangeForm, SetPasswordForm
43
from django.core.mail import send_mail, get_connection
44
from django.contrib.auth.tokens import default_token_generator
45
from django.core.urlresolvers import reverse
46
from django.utils.safestring import mark_safe
47
from django.utils.encoding import smart_str
48
from django.db import transaction
49
from django.core import validators
50

    
51
from synnefo.util import units
52
from synnefo_branding.utils import render_to_string
53
from synnefo.lib import join_urls
54
from astakos.im.fields import EmailField
55
from astakos.im.models import AstakosUser, EmailChange, Invitation, Resource, \
56
    PendingThirdPartyUser, get_latest_terms, ProjectApplication, Project
57
from astakos.im import presentation
58
from astakos.im.widgets import DummyWidget, RecaptchaWidget
59
from astakos.im.functions import send_change_email, submit_application, \
60
    accept_membership_project_checks, ProjectError
61

    
62
from astakos.im.util import reserved_verified_email, model_to_dict
63
from astakos.im import auth_providers
64
from astakos.im import settings
65
from astakos.im import auth
66

    
67
import astakos.im.messages as astakos_messages
68

    
69
import logging
70
import hashlib
71
import recaptcha.client.captcha as captcha
72
import re
73

    
74
logger = logging.getLogger(__name__)
75

    
76
DOMAIN_VALUE_REGEX = re.compile(
77
    r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}$',
78
    re.IGNORECASE)
79

    
80

    
81
class LocalUserCreationForm(UserCreationForm):
82
    """
83
    Extends the built in UserCreationForm in several ways:
84

85
    * Adds email, first_name, last_name, recaptcha_challenge_field,
86
    * recaptcha_response_field field.
87
    * The username field isn't visible and it is assigned a generated id.
88
    * User created is not active.
89
    """
90
    recaptcha_challenge_field = forms.CharField(widget=DummyWidget)
91
    recaptcha_response_field = forms.CharField(
92
        widget=RecaptchaWidget, label='')
93
    email = EmailField()
94

    
95
    class Meta:
96
        model = AstakosUser
97
        fields = ("email", "first_name", "last_name",
98
                  "has_signed_terms", "has_signed_terms")
99

    
100
    def __init__(self, *args, **kwargs):
101
        """
102
        Changes the order of fields, and removes the username field.
103
        """
104
        request = kwargs.pop('request', None)
105
        provider = kwargs.pop('provider', 'local')
106

    
107
        # we only use LocalUserCreationForm for local provider
108
        if not provider == 'local':
109
            raise Exception('Invalid provider')
110

    
111
        self.ip = None
112
        if request:
113
            self.ip = request.META.get('REMOTE_ADDR',
114
                                       request.META.get('HTTP_X_REAL_IP',
115
                                                        None))
116

    
117
        super(LocalUserCreationForm, self).__init__(*args, **kwargs)
118
        self.fields.keyOrder = ['email', 'first_name', 'last_name',
119
                                'password1', 'password2']
120

    
121
        if settings.RECAPTCHA_ENABLED:
122
            self.fields.keyOrder.extend(['recaptcha_challenge_field',
123
                                         'recaptcha_response_field', ])
124
        if get_latest_terms():
125
            self.fields.keyOrder.append('has_signed_terms')
126

    
127
        if 'has_signed_terms' in self.fields:
128
            # Overriding field label since we need to apply a link
129
            # to the terms within the label
130
            terms_link_html = '<a href="%s" target="_blank">%s</a>' \
131
                % (reverse('latest_terms'), _("the terms"))
132
            self.fields['has_signed_terms'].label = \
133
                mark_safe("I agree with %s" % terms_link_html)
134

    
135
    def clean_email(self):
136
        email = self.cleaned_data['email']
137
        if not email:
138
            raise forms.ValidationError(_(astakos_messages.REQUIRED_FIELD))
139
        if reserved_verified_email(email):
140
            raise forms.ValidationError(_(astakos_messages.EMAIL_USED))
141
        return email
142

    
143
    def clean_has_signed_terms(self):
144
        has_signed_terms = self.cleaned_data['has_signed_terms']
145
        if not has_signed_terms:
146
            raise forms.ValidationError(_(astakos_messages.SIGN_TERMS))
147
        return has_signed_terms
148

    
149
    def clean_recaptcha_response_field(self):
150
        if 'recaptcha_challenge_field' in self.cleaned_data:
151
            self.validate_captcha()
152
        return self.cleaned_data['recaptcha_response_field']
153

    
154
    def clean_recaptcha_challenge_field(self):
155
        if 'recaptcha_response_field' in self.cleaned_data:
156
            self.validate_captcha()
157
        return self.cleaned_data['recaptcha_challenge_field']
158

    
159
    def validate_captcha(self):
160
        rcf = self.cleaned_data['recaptcha_challenge_field']
161
        rrf = self.cleaned_data['recaptcha_response_field']
162
        check = captcha.submit(
163
            rcf, rrf, settings.RECAPTCHA_PRIVATE_KEY, self.ip)
164
        if not check.is_valid:
165
            raise forms.ValidationError(_(
166
                astakos_messages.CAPTCHA_VALIDATION_ERR))
167

    
168
    def create_user(self):
169
        try:
170
            data = self.cleaned_data
171
        except AttributeError:
172
            self.is_valid()
173
            data = self.cleaned_data
174

    
175
        user = auth.make_local_user(
176
            email=data['email'], password=data['password1'],
177
            first_name=data['first_name'], last_name=data['last_name'],
178
            has_signed_terms=True)
179
        return user
180

    
181

    
182
class ThirdPartyUserCreationForm(forms.ModelForm):
183
    email = EmailField(
184
        label='Contact email',
185
        help_text='This is needed for contact purposes. '
186
        'It doesn&#39;t need to be the same with the one you '
187
        'provided to login previously. '
188
    )
189

    
190
    class Meta:
191
        model = AstakosUser
192
        fields = ['email', 'first_name', 'last_name', 'has_signed_terms']
193

    
194
    def __init__(self, *args, **kwargs):
195
        """
196
        Changes the order of fields, and removes the username field.
197
        """
198

    
199
        self.provider = kwargs.pop('provider', None)
200
        self.request = kwargs.pop('request', None)
201
        if not self.provider or self.provider == 'local':
202
            raise Exception('Invalid provider, %r' % self.provider)
203

    
204
        # ThirdPartyUserCreationForm should always get instantiated with
205
        # a third_party_token value
206
        self.third_party_token = kwargs.pop('third_party_token', None)
207
        if not self.third_party_token:
208
            raise Exception('ThirdPartyUserCreationForm'
209
                            ' requires third_party_token')
210

    
211
        super(ThirdPartyUserCreationForm, self).__init__(*args, **kwargs)
212

    
213
        if not get_latest_terms():
214
            del self.fields['has_signed_terms']
215

    
216
        if 'has_signed_terms' in self.fields:
217
            # Overriding field label since we need to apply a link
218
            # to the terms within the label
219
            terms_link_html = '<a href="%s" target="_blank">%s</a>' \
220
                % (reverse('latest_terms'), _("the terms"))
221
            self.fields['has_signed_terms'].label = \
222
                mark_safe("I agree with %s" % terms_link_html)
223

    
224
    def clean_email(self):
225
        email = self.cleaned_data['email']
226
        if not email:
227
            raise forms.ValidationError(_(astakos_messages.REQUIRED_FIELD))
228
        if reserved_verified_email(email):
229
            provider_id = self.provider
230
            provider = auth_providers.get_provider(provider_id)
231
            extra_message = provider.get_add_to_existing_account_msg
232

    
233
            raise forms.ValidationError(mark_safe(
234
                _(astakos_messages.EMAIL_USED) + ' ' + extra_message))
235
        return email
236

    
237
    def clean_has_signed_terms(self):
238
        has_signed_terms = self.cleaned_data['has_signed_terms']
239
        if not has_signed_terms:
240
            raise forms.ValidationError(_(astakos_messages.SIGN_TERMS))
241
        return has_signed_terms
242

    
243
    def _get_pending_user(self):
244
        return PendingThirdPartyUser.objects.get(token=self.third_party_token)
245

    
246
    def create_user(self):
247
        try:
248
            data = self.cleaned_data
249
        except AttributeError:
250
            self.is_valid()
251
            data = self.cleaned_data
252

    
253
        user = auth.make_user(
254
            email=data["email"],
255
            first_name=data["first_name"], last_name=data["last_name"],
256
            has_signed_terms=True)
257
        pending = self._get_pending_user()
258
        provider = pending.get_provider(user)
259
        provider.add_to_user()
260
        pending.delete()
261
        return user
262

    
263

    
264
class LoginForm(AuthenticationForm):
265
    username = EmailField(label=_("Email"))
266
    recaptcha_challenge_field = forms.CharField(widget=DummyWidget)
267
    recaptcha_response_field = forms.CharField(
268
        widget=RecaptchaWidget, label='')
269

    
270
    def __init__(self, *args, **kwargs):
271
        was_limited = kwargs.get('was_limited', False)
272
        request = kwargs.get('request', None)
273
        if request:
274
            self.ip = request.META.get(
275
                'REMOTE_ADDR',
276
                request.META.get('HTTP_X_REAL_IP', None))
277

    
278
        t = ('request', 'was_limited')
279
        for elem in t:
280
            if elem in kwargs.keys():
281
                kwargs.pop(elem)
282
        super(LoginForm, self).__init__(*args, **kwargs)
283

    
284
        self.fields.keyOrder = ['username', 'password']
285
        if was_limited and settings.RECAPTCHA_ENABLED:
286
            self.fields.keyOrder.extend(['recaptcha_challenge_field',
287
                                         'recaptcha_response_field', ])
288

    
289
    def clean_username(self):
290
        return self.cleaned_data['username'].lower()
291

    
292
    def clean_recaptcha_response_field(self):
293
        if 'recaptcha_challenge_field' in self.cleaned_data:
294
            self.validate_captcha()
295
        return self.cleaned_data['recaptcha_response_field']
296

    
297
    def clean_recaptcha_challenge_field(self):
298
        if 'recaptcha_response_field' in self.cleaned_data:
299
            self.validate_captcha()
300
        return self.cleaned_data['recaptcha_challenge_field']
301

    
302
    def validate_captcha(self):
303
        rcf = self.cleaned_data['recaptcha_challenge_field']
304
        rrf = self.cleaned_data['recaptcha_response_field']
305
        check = captcha.submit(
306
            rcf, rrf, settings.RECAPTCHA_PRIVATE_KEY, self.ip)
307
        if not check.is_valid:
308
            raise forms.ValidationError(_(
309
                astakos_messages.CAPTCHA_VALIDATION_ERR))
310

    
311
    def clean(self):
312
        """
313
        Override default behavior in order to check user's activation later
314
        """
315
        username = self.cleaned_data.get('username')
316

    
317
        if username:
318
            try:
319
                user = AstakosUser.objects.get_by_identifier(username)
320
                if not user.has_auth_provider('local'):
321
                    provider = auth_providers.get_provider('local', user)
322
                    msg = provider.get_login_disabled_msg
323
                    raise forms.ValidationError(mark_safe(msg))
324
            except AstakosUser.DoesNotExist:
325
                pass
326

    
327
        try:
328
            super(LoginForm, self).clean()
329
        except forms.ValidationError:
330
            if self.user_cache is None:
331
                raise
332
            if not self.user_cache.is_active:
333
                msg = self.user_cache.get_inactive_message('local')
334
                raise forms.ValidationError(msg)
335
            if self.request:
336
                if not self.request.session.test_cookie_worked():
337
                    raise
338
        return self.cleaned_data
339

    
340

    
341
class ProfileForm(forms.ModelForm):
342
    """
343
    Subclass of ``ModelForm`` for permiting user to edit his/her profile.
344
    Most of the fields are readonly since the user is not allowed to change
345
    them.
346

347
    The class defines a save method which sets ``is_verified`` to True so as
348
    the user during the next login will not to be redirected to profile page.
349
    """
350
    email = EmailField(label='E-mail address',
351
                       help_text='E-mail address')
352
    renew = forms.BooleanField(label='Renew token', required=False)
353

    
354
    class Meta:
355
        model = AstakosUser
356
        fields = ('email', 'first_name', 'last_name')
357

    
358
    def __init__(self, *args, **kwargs):
359
        self.session_key = kwargs.pop('session_key', None)
360
        super(ProfileForm, self).__init__(*args, **kwargs)
361
        instance = getattr(self, 'instance', None)
362
        ro_fields = ('email',)
363
        if instance and instance.id:
364
            for field in ro_fields:
365
                self.fields[field].widget.attrs['readonly'] = True
366

    
367
    def clean_email(self):
368
        return self.instance.email
369

    
370
    def save(self, commit=True, **kwargs):
371
        user = super(ProfileForm, self).save(commit=False, **kwargs)
372
        user.is_verified = True
373
        if self.cleaned_data.get('renew'):
374
            user.renew_token(
375
                flush_sessions=True,
376
                current_key=self.session_key
377
            )
378
        if commit:
379
            user.save(**kwargs)
380
        return user
381

    
382

    
383
class FeedbackForm(forms.Form):
384
    """
385
    Form for writing feedback.
386
    """
387
    feedback_msg = forms.CharField(widget=forms.Textarea, label=u'Message')
388
    feedback_data = forms.CharField(widget=forms.HiddenInput(), label='',
389
                                    required=False)
390

    
391

    
392
class SendInvitationForm(forms.Form):
393
    """
394
    Form for sending an invitations
395
    """
396

    
397
    email = EmailField(required=True, label='Email address')
398
    first_name = EmailField(label='First name')
399
    last_name = EmailField(label='Last name')
400

    
401

    
402
class ExtendedPasswordResetForm(PasswordResetForm):
403
    """
404
    Extends PasswordResetForm by overriding
405

406
    save method: to pass a custom from_email in send_mail.
407
    clean_email: to handle local auth provider checks
408
    """
409
    def clean_email(self):
410
        # we override the default django auth clean_email to provide more
411
        # detailed messages in case of inactive users
412
        email = self.cleaned_data['email']
413
        try:
414
            user = AstakosUser.objects.get_by_identifier(email)
415
            self.users_cache = [user]
416
            if not user.is_active:
417
                if not user.has_auth_provider('local', auth_backend='astakos'):
418
                    provider = auth_providers.get_provider('local', user)
419
                    msg = mark_safe(provider.get_unusable_password_msg)
420
                    raise forms.ValidationError(msg)
421

    
422
                msg = mark_safe(user.get_inactive_message('local'))
423
                raise forms.ValidationError(msg)
424

    
425
            provider = auth_providers.get_provider('local', user)
426
            if not user.has_usable_password():
427
                msg = provider.get_unusable_password_msg
428
                raise forms.ValidationError(mark_safe(msg))
429

    
430
            if not user.can_change_password():
431
                msg = provider.get_cannot_change_password_msg
432
                raise forms.ValidationError(mark_safe(msg))
433

    
434
        except AstakosUser.DoesNotExist:
435
            raise forms.ValidationError(_(astakos_messages.EMAIL_UNKNOWN))
436
        return email
437

    
438
    def save(self, domain_override=None,
439
             email_template_name='registration/password_reset_email.html',
440
             use_https=False, token_generator=default_token_generator,
441
             request=None, **kwargs):
442
        """
443
        Generates a one-use only link for resetting password and sends to the
444
        user.
445

446
        """
447
        for user in self.users_cache:
448
            url = user.astakosuser.get_password_reset_url(token_generator)
449
            url = join_urls(settings.BASE_HOST, url)
450
            c = {
451
                'email': user.email,
452
                'url': url,
453
                'site_name': settings.SITENAME,
454
                'user': user,
455
                'baseurl': settings.BASE_URL,
456
                'support': settings.CONTACT_EMAIL
457
            }
458
            message = render_to_string(email_template_name, c)
459
            from_email = settings.SERVER_EMAIL
460
            send_mail(_(astakos_messages.PASSWORD_RESET_EMAIL_SUBJECT),
461
                      message,
462
                      from_email,
463
                      [user.email],
464
                      connection=get_connection())
465

    
466

    
467
class EmailChangeForm(forms.ModelForm):
468

    
469
    new_email_address = EmailField()
470

    
471
    class Meta:
472
        model = EmailChange
473
        fields = ('new_email_address',)
474

    
475
    def clean_new_email_address(self):
476
        addr = self.cleaned_data['new_email_address']
477
        if reserved_verified_email(addr):
478
            raise forms.ValidationError(_(astakos_messages.EMAIL_USED))
479
        return addr
480

    
481
    def save(self, request,
482
             email_template_name='registration/email_change_email.txt',
483
             commit=True, **kwargs):
484
        ec = super(EmailChangeForm, self).save(commit=False, **kwargs)
485
        ec.user = request.user
486
        # delete pending email changes
487
        request.user.emailchanges.all().delete()
488

    
489
        activation_key = hashlib.sha1(
490
            str(random()) + smart_str(ec.new_email_address))
491
        ec.activation_key = activation_key.hexdigest()
492
        if commit:
493
            ec.save(**kwargs)
494
        send_change_email(ec, request, email_template_name=email_template_name)
495

    
496

    
497
class SignApprovalTermsForm(forms.ModelForm):
498

    
499
    class Meta:
500
        model = AstakosUser
501
        fields = ("has_signed_terms",)
502

    
503
    def __init__(self, *args, **kwargs):
504
        super(SignApprovalTermsForm, self).__init__(*args, **kwargs)
505

    
506
    def clean_has_signed_terms(self):
507
        has_signed_terms = self.cleaned_data['has_signed_terms']
508
        if not has_signed_terms:
509
            raise forms.ValidationError(_(astakos_messages.SIGN_TERMS))
510
        return has_signed_terms
511

    
512
    def save(self, commit=True, **kwargs):
513
        user = super(SignApprovalTermsForm, self).save(commit=commit, **kwargs)
514
        user.date_signed_terms = datetime.now()
515
        if commit:
516
            user.save(**kwargs)
517
        return user
518

    
519

    
520
class InvitationForm(forms.ModelForm):
521

    
522
    username = EmailField(label=_("Email"))
523

    
524
    def __init__(self, *args, **kwargs):
525
        super(InvitationForm, self).__init__(*args, **kwargs)
526

    
527
    class Meta:
528
        model = Invitation
529
        fields = ('username', 'realname')
530

    
531
    def clean_username(self):
532
        username = self.cleaned_data['username']
533
        try:
534
            Invitation.objects.get(username=username)
535
            raise forms.ValidationError(
536
                _(astakos_messages.INVITATION_EMAIL_EXISTS))
537
        except Invitation.DoesNotExist:
538
            pass
539
        return username
540

    
541

    
542
class ExtendedPasswordChangeForm(PasswordChangeForm):
543
    """
544
    Extends PasswordChangeForm by enabling user
545
    to optionally renew also the token.
546
    """
547
    if not settings.NEWPASSWD_INVALIDATE_TOKEN:
548
        renew = forms.BooleanField(
549
            label='Renew token', required=False,
550
            initial=True,
551
            help_text='Unsetting this may result in security risk.')
552

    
553
    def __init__(self, user, *args, **kwargs):
554
        self.session_key = kwargs.pop('session_key', None)
555
        super(ExtendedPasswordChangeForm, self).__init__(user, *args, **kwargs)
556

    
557
    def save(self, commit=True, **kwargs):
558
        try:
559
            if settings.NEWPASSWD_INVALIDATE_TOKEN or \
560
                    self.cleaned_data.get('renew'):
561
                self.user.renew_token()
562
            self.user.flush_sessions(current_key=self.session_key)
563
        except AttributeError:
564
            # if user model does has not such methods
565
            pass
566
        return super(ExtendedPasswordChangeForm, self).save(commit=commit,
567
                                                            **kwargs)
568

    
569

    
570
class ExtendedSetPasswordForm(SetPasswordForm):
571
    """
572
    Extends SetPasswordForm by enabling user
573
    to optionally renew also the token.
574
    """
575
    if not settings.NEWPASSWD_INVALIDATE_TOKEN:
576
        renew = forms.BooleanField(
577
            label='Renew token',
578
            required=False,
579
            initial=True,
580
            help_text='Unsetting this may result in security risk.')
581

    
582
    def __init__(self, user, *args, **kwargs):
583
        super(ExtendedSetPasswordForm, self).__init__(user, *args, **kwargs)
584

    
585
    @transaction.commit_on_success()
586
    def save(self, commit=True, **kwargs):
587
        try:
588
            self.user = AstakosUser.objects.get(id=self.user.id)
589
            if settings.NEWPASSWD_INVALIDATE_TOKEN or \
590
                    self.cleaned_data.get('renew'):
591
                self.user.renew_token()
592

    
593
            provider = auth_providers.get_provider('local', self.user)
594
            if provider.get_add_policy:
595
                provider.add_to_user()
596

    
597
        except BaseException, e:
598
            logger.exception(e)
599
        return super(ExtendedSetPasswordForm, self).save(commit=commit,
600
                                                         **kwargs)
601

    
602

    
603
app_name_label = "Project name"
604
app_name_placeholder = _("myproject.mylab.ntua.gr")
605
app_name_validator = validators.RegexValidator(
606
    DOMAIN_VALUE_REGEX,
607
    _(astakos_messages.DOMAIN_VALUE_ERR),
608
    'invalid')
609
app_name_help = _("""
610
        The project's name should be in a domain format.
611
        The domain shouldn't neccessarily exist in the real
612
        world but is helpful to imply a structure.
613
        e.g.: myproject.mylab.ntua.gr or
614
        myservice.myteam.myorganization""")
615
app_name_widget = forms.TextInput(
616
    attrs={'placeholder': app_name_placeholder})
617

    
618

    
619
app_home_label = "Homepage URL"
620
app_home_placeholder = 'myinstitution.org/myproject/'
621
app_home_help = _("""
622
        URL pointing at your project's site.
623
        e.g.: myinstitution.org/myproject/.
624
        Leave blank if there is no website.""")
625
app_home_widget = forms.TextInput(
626
    attrs={'placeholder': app_home_placeholder})
627

    
628
app_desc_label = _("Description")
629
app_desc_help = _("""
630
        Please provide a short but descriptive abstract of your
631
        project, so that anyone searching can quickly understand
632
        what this project is about.""")
633

    
634
app_comment_label = _("Comments for review (private)")
635
app_comment_help = _("""
636
        Write down any comments you may have for the reviewer
637
        of this application (e.g. background and rationale to
638
        support your request).
639
        The comments are strictly for the review process
640
        and will not be made public.""")
641

    
642
app_start_date_label = _("Start date")
643
app_start_date_help = _("""
644
        Provide a date when your need your project to be created,
645
        and members to be able to join and get resources.
646
        This date is only a hint to help prioritize reviews.""")
647

    
648
app_end_date_label = _("Termination date")
649
app_end_date_help = _("""
650
        At this date, the project will be automatically terminated
651
        and its resource grants revoked from all members. If you are
652
        not certain, it is best to start with a conservative estimation.
653
        You can always re-apply for an extension, if you need.""")
654

    
655
join_policy_label = _("Joining policy")
656
app_member_join_policy_help = _("""
657
        Select how new members are accepted into the project.""")
658
leave_policy_label = _("Leaving policy")
659
app_member_leave_policy_help = _("""
660
        Select how new members can leave the project.""")
661

    
662
max_members_label = _("Maximum member count")
663
max_members_help = _("""
664
        Specify the maximum number of members this project may have,
665
        including the owner. Beyond this number, no new members
666
        may join the project and be granted the project resources.
667
        If you are not certain, it is best to start with a conservative
668
        limit. You can always request a raise when you need it.""")
669

    
670
join_policies = presentation.PROJECT_MEMBER_JOIN_POLICIES.items()
671
leave_policies = presentation.PROJECT_MEMBER_LEAVE_POLICIES.items()
672

    
673

    
674
class ProjectApplicationForm(forms.ModelForm):
675

    
676
    name = forms.CharField(
677
        label=app_name_label,
678
        help_text=app_name_help,
679
        widget=app_name_widget,
680
        validators=[app_name_validator])
681

    
682
    homepage = forms.URLField(
683
        label=app_home_label,
684
        help_text=app_home_help,
685
        widget=app_home_widget,
686
        required=False)
687

    
688
    description = forms.CharField(
689
        label=app_desc_label,
690
        help_text=app_desc_help,
691
        widget=forms.Textarea,
692
        required=False)
693

    
694
    comments = forms.CharField(
695
        label=app_comment_label,
696
        help_text=app_comment_help,
697
        widget=forms.Textarea,
698
        required=False)
699

    
700
    start_date = forms.DateTimeField(
701
        label=app_start_date_label,
702
        help_text=app_start_date_help,
703
        required=False)
704

    
705
    end_date = forms.DateTimeField(
706
        label=app_end_date_label,
707
        help_text=app_end_date_help)
708

    
709
    member_join_policy = forms.TypedChoiceField(
710
        label=join_policy_label,
711
        help_text=app_member_join_policy_help,
712
        initial=2,
713
        coerce=int,
714
        choices=join_policies)
715

    
716
    member_leave_policy = forms.TypedChoiceField(
717
        label=leave_policy_label,
718
        help_text=app_member_leave_policy_help,
719
        coerce=int,
720
        choices=leave_policies)
721

    
722
    limit_on_members_number = forms.IntegerField(
723
        label=max_members_label,
724
        help_text=max_members_help,
725
        min_value=0,
726
        required=True)
727

    
728
    class Meta:
729
        model = ProjectApplication
730
        fields = ('name', 'homepage', 'description',
731
                  'start_date', 'end_date', 'comments',
732
                  'member_join_policy', 'member_leave_policy',
733
                  'limit_on_members_number')
734

    
735
    def __init__(self, *args, **kwargs):
736
        instance = kwargs.get('instance')
737

    
738
        super(ProjectApplicationForm, self).__init__(*args, **kwargs)
739
        # in case of new application remove closed join policy
740
        if not instance:
741
            policies = presentation.PROJECT_MEMBER_JOIN_POLICIES.copy()
742
            policies.pop(3)
743
            self.fields['member_join_policy'].choices = policies.iteritems()
744

    
745
    def clean_start_date(self):
746
        start_date = self.cleaned_data.get('start_date')
747
        if not self.instance:
748
            today = datetime.now()
749
            today = datetime(today.year, today.month, today.day)
750
            if start_date and (start_date - today).days < 0:
751
                raise forms.ValidationError(
752
                    _(astakos_messages.INVALID_PROJECT_START_DATE))
753
        return start_date
754

    
755
    def clean_end_date(self):
756
        start_date = self.cleaned_data.get('start_date')
757
        end_date = self.cleaned_data.get('end_date')
758
        today = datetime.now()
759
        today = datetime(today.year, today.month, today.day)
760
        if end_date and (end_date - today).days < 0:
761
            raise forms.ValidationError(
762
                _(astakos_messages.INVALID_PROJECT_END_DATE))
763
        if start_date and (end_date - start_date).days <= 0:
764
            raise forms.ValidationError(
765
                _(astakos_messages.INCONSISTENT_PROJECT_DATES))
766
        return end_date
767

    
768
    def clean(self):
769
        userid = self.data.get('user', None)
770
        self.resource_policies
771
        self.user = None
772
        if userid:
773
            try:
774
                self.user = AstakosUser.objects.get(id=userid)
775
            except AstakosUser.DoesNotExist:
776
                pass
777
        if not self.user:
778
            raise forms.ValidationError(_(astakos_messages.NO_APPLICANT))
779
        super(ProjectApplicationForm, self).clean()
780
        return self.cleaned_data
781

    
782
    @property
783
    def resource_policies(self):
784
        policies = []
785
        append = policies.append
786
        resource_indexes = {}
787
        include_diffs = False
788

    
789
        existing_policies = []
790
        if self.instance and self.instance.pk:
791
            include_diffs = True
792
            existing_policies = self.instance.resource_set
793

    
794
        for name, value in self.data.iteritems():
795
            if not value:
796
                continue
797

    
798
            if name.endswith('_uplimit'):
799
                is_project_limit = name.endswith('_p_uplimit')
800
                suffix = '_p_uplimit' if is_project_limit else '_m_uplimit'
801
                uplimit = value
802
                prefix, _suffix = name.split(suffix)
803

    
804
                try:
805
                    resource = Resource.objects.get(name=prefix)
806
                except Resource.DoesNotExist:
807
                    raise forms.ValidationError("Resource %s does not exist" %
808
                                                resource.name)
809

    
810
                # keep only resource limits for selected resource groups
811
                if self.data.get('is_selected_%s' % \
812
                                     resource.group, "0") == "1":
813
                    if not resource.ui_visible:
814
                        raise forms.ValidationError("Invalid resource %s" %
815
                                                    resource.name)
816
                    d = model_to_dict(resource)
817
                    try:
818
                        uplimit = long(uplimit)
819
                    except ValueError:
820
                        m = "Limit should be an integer"
821
                        raise forms.ValidationError(m)
822

    
823
                    display = units.show(uplimit, resource.unit)
824
                    existing = resource_indexes.get(prefix)
825

    
826
                    diff_data = None
827
                    if include_diffs:
828
                        try:
829
                            policy = existing_policies.get(resource=resource)
830
                            if is_project_limit:
831
                                pval = policy.project_capacity
832
                            else:
833
                                pval = policy.member_capacity
834

    
835
                            if pval != uplimit:
836
                                diff = pval - uplimit
837
                                diff_data = {
838
                                    'prev': pval,
839
                                    'prev_display': units.show(pval,
840
                                                               resource.unit),
841
                                    'diff': diff,
842
                                    'diff_display': units.show(abs(diff),
843
                                                               resource.unit),
844
                                    'increased': diff < 0,
845
                                    'operator': '+' if diff < 0 else '-'
846
                                }
847

    
848
                        except:
849
                            pass
850

    
851
                    if is_project_limit:
852
                        d.update(dict(resource=prefix,
853
                                      p_uplimit=uplimit,
854
                                      display_p_uplimit=display))
855

    
856
                        if diff_data:
857
                            d.update(dict(resource=prefix, p_diff=diff_data))
858

    
859
                        if not existing:
860
                            d.update(dict(resource=prefix, m_uplimit=0,
861
                                      display_m_uplimit=units.show(0,
862
                                           resource.unit)))
863
                    else:
864
                        d.update(dict(resource=prefix, m_uplimit=uplimit,
865
                                      display_m_uplimit=display))
866

    
867
                        if diff_data:
868
                            d.update(dict(resource=prefix, m_diff=diff_data))
869

    
870
                        if not existing:
871
                            d.update(dict(resource=prefix, p_uplimit=0,
872
                                      display_p_uplimit=units.show(0,
873
                                           resource.unit)))
874

    
875
                    if resource_indexes.get(prefix, None) is not None:
876
                        # already included in policies
877
                        existing.update(d)
878
                    else:
879
                        # keep track of resource dicts
880
                        append(d)
881
                        resource_indexes[prefix] = d
882

    
883
        ordered_keys = presentation.RESOURCES['resources_order']
884

    
885
        def resource_order(r):
886
            if r['str_repr'] in ordered_keys:
887
                return ordered_keys.index(r['str_repr'])
888
            else:
889
                return -1
890

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

    
894
    def cleaned_resource_policies(self):
895
        policies = {}
896
        for d in self.resource_policies:
897
            if self.instance.pk:
898
                if not d.get('p_diff', None) and not d.get('m_diff', None):
899
                    continue
900

    
901
            policies[d["name"]] = {
902
                "project_capacity": d.get("p_uplimit", 0),
903
                "member_capacity": d.get("m_uplimit", 0)
904
            }
905

    
906
        if len(policies.keys()) == 0:
907
            return {}
908

    
909
        return policies
910

    
911
    def fill_api_data(self):
912
        data = dict(self.cleaned_data)
913
        is_new = self.instance.id is None
914
        if isinstance(self.instance, Project):
915
            data['project_id'] = self.instance.id
916
        else:
917
            data['project_id'] = self.instance.chain.id if not is_new else None
918

    
919
        data['owner'] = self.user.uuid if is_new else self.instance.owner.uuid
920
        data['resources'] = self.cleaned_resource_policies()
921
        data['request_user'] = self.user
922
        if data.get('start_date', None):
923
            data['start_date'] = date_util.isoformat(data.get('start_date'))
924
        data['end_date'] = date_util.isoformat(data.get('end_date'))
925
        data['max_members'] = data.get('limit_on_members_number')
926
        return data
927

    
928
    def save(self, commit=True, **kwargs):
929
        from astakos.api import projects as api
930
        data = self.fill_api_data()
931
        return api.submit_new_project(data, self.user)
932

    
933

    
934
class ProjectModificationForm(ProjectApplicationForm):
935

    
936
    class Meta:
937
        model = Project
938
        fields = ('name', 'homepage', 'description',
939
                  'end_date', 'comments', 'member_join_policy',
940
                  'member_leave_policy', 'limit_on_members_number')
941

    
942
    def save(self, commit=True, **kwargs):
943
        from astakos.api import projects as api
944
        data = self.fill_api_data()
945
        return api.submit_modification(data, self.user, self.instance.uuid)
946

    
947

    
948
class ProjectSortForm(forms.Form):
949
    sorting = forms.ChoiceField(
950
        label='Sort by',
951
        choices=(('name', 'Sort by Name'),
952
                 ('issue_date', 'Sort by Issue date'),
953
                 ('start_date', 'Sort by Start Date'),
954
                 ('end_date', 'Sort by End Date'),
955
                 # ('approved_members_num', 'Sort by Participants'),
956
                 ('state', 'Sort by Status'),
957
                 ('member_join_policy__description',
958
                  'Sort by Member Join Policy'),
959
                 ('member_leave_policy__description',
960
                  'Sort by Member Leave Policy'),
961
                 ('-name', 'Sort by Name'),
962
                 ('-issue_date', 'Sort by Issue date'),
963
                 ('-start_date', 'Sort by Start Date'),
964
                 ('-end_date', 'Sort by End Date'),
965
                 # ('-approved_members_num', 'Sort by Participants'),
966
                 ('-state', 'Sort by Status'),
967
                 ('-member_join_policy__description',
968
                  'Sort by Member Join Policy'),
969
                 ('-member_leave_policy__description',
970
                  'Sort by Member Leave Policy')
971
                 ),
972
        required=True
973
    )
974

    
975

    
976
class AddProjectMembersForm(forms.Form):
977
    q = forms.CharField(
978
        widget=forms.Textarea(
979
            attrs={
980
                'placeholder':
981
                astakos_messages.ADD_PROJECT_MEMBERS_Q_PLACEHOLDER}),
982
        label=_('Add members'),
983
        help_text=_(astakos_messages.ADD_PROJECT_MEMBERS_Q_HELP),
984
        required=True,)
985

    
986
    def __init__(self, *args, **kwargs):
987
        project_id = kwargs.pop('project_id', None)
988
        if project_id:
989
            self.project = Project.objects.get(id=project_id)
990
        self.request_user = kwargs.pop('request_user', None)
991
        super(AddProjectMembersForm, self).__init__(*args, **kwargs)
992

    
993
    def clean(self):
994
        try:
995
            accept_membership_project_checks(self.project, self.request_user)
996
        except ProjectError as e:
997
            raise forms.ValidationError(e)
998

    
999
        q = self.cleaned_data.get('q') or ''
1000
        users = re.split("\r\n|\n|,", q)
1001
        users = list(u.strip() for u in users if u)
1002
        db_entries = AstakosUser.objects.accepted().filter(email__in=users)
1003
        unknown = list(set(users) - set(u.email for u in db_entries))
1004
        if unknown:
1005
            raise forms.ValidationError(
1006
                _(astakos_messages.UNKNOWN_USERS) % ','.join(unknown))
1007
        self.valid_users = db_entries
1008
        return self.cleaned_data
1009

    
1010
    def get_valid_users(self):
1011
        """Should be called after form cleaning"""
1012
        return self.valid_users
1013

    
1014

    
1015
class ProjectMembersSortForm(forms.Form):
1016
    sorting = forms.ChoiceField(
1017
        label='Sort by',
1018
        choices=(('person__email', 'User Id'),
1019
                 ('person__first_name', 'Name'),
1020
                 ('acceptance_date', 'Acceptance date')
1021
                 ),
1022
        required=True
1023
    )
1024

    
1025

    
1026
class ProjectSearchForm(forms.Form):
1027
    q = forms.CharField(max_length=200, label='Search project', required=False)
1028

    
1029

    
1030
class ExtendedProfileForm(ProfileForm):
1031
    """
1032
    Profile form that combines `email change` and `password change` user
1033
    actions by propagating submited data to internal EmailChangeForm
1034
    and ExtendedPasswordChangeForm objects.
1035
    """
1036

    
1037
    password_change_form = None
1038
    email_change_form = None
1039

    
1040
    password_change = False
1041
    email_change = False
1042

    
1043
    extra_forms_fields = {
1044
        'email': ['new_email_address'],
1045
        'password': ['old_password', 'new_password1', 'new_password2']
1046
    }
1047

    
1048
    fields = ('email')
1049
    change_password = forms.BooleanField(initial=False, required=False)
1050
    change_email = forms.BooleanField(initial=False, required=False)
1051

    
1052
    email_changed = False
1053
    password_changed = False
1054

    
1055
    def __init__(self, *args, **kwargs):
1056
        session_key = kwargs.get('session_key', None)
1057
        self.fields_list = [
1058
            'email',
1059
            'new_email_address',
1060
            'first_name',
1061
            'last_name',
1062
            'old_password',
1063
            'new_password1',
1064
            'new_password2',
1065
            'change_email',
1066
            'change_password',
1067
        ]
1068

    
1069
        super(ExtendedProfileForm, self).__init__(*args, **kwargs)
1070
        self.session_key = session_key
1071
        if self.instance.can_change_password():
1072
            self.password_change = True
1073
        else:
1074
            self.fields_list.remove('old_password')
1075
            self.fields_list.remove('new_password1')
1076
            self.fields_list.remove('new_password2')
1077
            self.fields_list.remove('change_password')
1078
            del self.fields['change_password']
1079

    
1080
        if settings.EMAILCHANGE_ENABLED and self.instance.can_change_email():
1081
            self.email_change = True
1082
        else:
1083
            self.fields_list.remove('new_email_address')
1084
            self.fields_list.remove('change_email')
1085
            del self.fields['change_email']
1086

    
1087
        self._init_extra_forms()
1088
        self.save_extra_forms = []
1089
        self.success_messages = []
1090
        self.fields.keyOrder = self.fields_list
1091

    
1092
    def _init_extra_form_fields(self):
1093
        if self.email_change:
1094
            self.fields.update(self.email_change_form.fields)
1095
            self.fields['new_email_address'].required = False
1096
            self.fields['email'].help_text = _(
1097
                'Change the email associated with '
1098
                'your account. This email will '
1099
                'remain active until you verify '
1100
                'your new one.')
1101

    
1102
        if self.password_change:
1103
            self.fields.update(self.password_change_form.fields)
1104
            self.fields['old_password'].required = False
1105
            self.fields['old_password'].label = _('Password')
1106
            self.fields['old_password'].help_text = _('Change your password.')
1107
            self.fields['old_password'].initial = 'password'
1108
            self.fields['old_password'].widget.render_value = True
1109
            self.fields['new_password1'].required = False
1110
            self.fields['new_password2'].required = False
1111

    
1112
    def _update_extra_form_errors(self):
1113
        if self.cleaned_data.get('change_password'):
1114
            self.errors.update(self.password_change_form.errors)
1115
        if self.cleaned_data.get('change_email'):
1116
            self.errors.update(self.email_change_form.errors)
1117

    
1118
    def _init_extra_forms(self):
1119
        self.email_change_form = EmailChangeForm(self.data)
1120
        self.password_change_form = ExtendedPasswordChangeForm(
1121
            user=self.instance,
1122
            data=self.data, session_key=self.session_key)
1123
        self._init_extra_form_fields()
1124

    
1125
    def is_valid(self):
1126
        password, email = True, True
1127
        profile = super(ExtendedProfileForm, self).is_valid()
1128
        if profile and self.cleaned_data.get('change_password', None):
1129
            self.password_change_form.fields['new_password1'].required = True
1130
            self.password_change_form.fields['new_password2'].required = True
1131
            password = self.password_change_form.is_valid()
1132
            self.save_extra_forms.append('password')
1133
        if profile and self.cleaned_data.get('change_email'):
1134
            self.fields['new_email_address'].required = True
1135
            email = self.email_change_form.is_valid()
1136
            self.save_extra_forms.append('email')
1137

    
1138
        if not password or not email:
1139
            self._update_extra_form_errors()
1140

    
1141
        return all([profile, password, email])
1142

    
1143
    def save(self, request, *args, **kwargs):
1144
        if 'email' in self.save_extra_forms:
1145
            self.email_change_form.save(request, *args, **kwargs)
1146
            self.email_changed = True
1147
        if 'password' in self.save_extra_forms:
1148
            self.password_change_form.save(*args, **kwargs)
1149
            self.password_changed = True
1150
        return super(ExtendedProfileForm, self).save(*args, **kwargs)