Statistics
| Branch: | Tag: | Revision:

root / snf-astakos-app / astakos / im / models.py @ 39c9b4e0

History | View | Annotate | Download (86.7 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

    
34
import hashlib
35
import uuid
36
import logging
37
import json
38
import math
39

    
40
from time import asctime
41
from datetime import datetime, timedelta
42
from base64 import b64encode
43
from urlparse import urlparse
44
from urllib import quote
45
from random import randint
46
from collections import defaultdict, namedtuple
47

    
48
from django.db import models, IntegrityError, transaction
49
from django.contrib.auth.models import User, UserManager, Group, Permission
50
from django.utils.translation import ugettext as _
51
from django.core.exceptions import ValidationError
52
from django.db.models.signals import (
53
    pre_save, post_save, post_syncdb, post_delete)
54
from django.contrib.contenttypes.models import ContentType
55

    
56
from django.dispatch import Signal
57
from django.db.models import Q
58
from django.core.urlresolvers import reverse
59
from django.utils.http import int_to_base36
60
from django.contrib.auth.tokens import default_token_generator
61
from django.conf import settings
62
from django.utils.importlib import import_module
63
from django.utils.safestring import mark_safe
64
from django.core.validators import email_re
65
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist
66

    
67
from astakos.im.settings import (
68
    DEFAULT_USER_LEVEL, INVITATIONS_PER_LEVEL,
69
    AUTH_TOKEN_DURATION, EMAILCHANGE_ACTIVATION_DAYS, LOGGING_LEVEL,
70
    SITENAME, SERVICES, MODERATION_ENABLED, RESOURCES_PRESENTATION_DATA,
71
    PROJECT_MEMBER_JOIN_POLICIES, PROJECT_MEMBER_LEAVE_POLICIES)
72
from astakos.im import settings as astakos_settings
73
from astakos.im.endpoints.qh import (
74
    register_users, send_quotas, qh_check_users, qh_get_quota_limits,
75
    register_services, register_resources, qh_add_quota, QuotaLimits,
76
    qh_query_serials, qh_ack_serials,
77
    QuotaValues, add_quota_values)
78
from astakos.im import auth_providers
79

    
80
import astakos.im.messages as astakos_messages
81
from astakos.im.lock import with_lock
82
from .managers import ForUpdateManager
83

    
84
from synnefo.lib.quotaholder.api import QH_PRACTICALLY_INFINITE
85
from synnefo.lib.db.intdecimalfield import intDecimalField
86

    
87
logger = logging.getLogger(__name__)
88

    
89
DEFAULT_CONTENT_TYPE = None
90
_content_type = None
91

    
92
def get_content_type():
93
    global _content_type
94
    if _content_type is not None:
95
        return _content_type
96

    
97
    try:
98
        content_type = ContentType.objects.get(app_label='im', model='astakosuser')
99
    except:
100
        content_type = DEFAULT_CONTENT_TYPE
101
    _content_type = content_type
102
    return content_type
103

    
104
RESOURCE_SEPARATOR = '.'
105

    
106
inf = float('inf')
107

    
108
class Service(models.Model):
109
    name = models.CharField(_('Name'), max_length=255, unique=True, db_index=True)
110
    url = models.FilePathField()
111
    icon = models.FilePathField(blank=True)
112
    auth_token = models.CharField(_('Authentication Token'), max_length=32,
113
                                  null=True, blank=True)
114
    auth_token_created = models.DateTimeField(_('Token creation date'), null=True)
115
    auth_token_expires = models.DateTimeField(
116
        _('Token expiration date'), null=True)
117
    order = models.PositiveIntegerField(default=0)
118

    
119
    class Meta:
120
        ordering = ('order', )
121

    
122
    def renew_token(self, expiration_date=None):
123
        md5 = hashlib.md5()
124
        md5.update(self.name.encode('ascii', 'ignore'))
125
        md5.update(self.url.encode('ascii', 'ignore'))
126
        md5.update(asctime())
127

    
128
        self.auth_token = b64encode(md5.digest())
129
        self.auth_token_created = datetime.now()
130
        if expiration_date:
131
            self.auth_token_expires = expiration_date
132
        else:
133
            self.auth_token_expires = None
134

    
135
    def __str__(self):
136
        return self.name
137

    
138
    @property
139
    def resources(self):
140
        return self.resource_set.all()
141

    
142
    @resources.setter
143
    def resources(self, resources):
144
        for s in resources:
145
            self.resource_set.create(**s)
146

    
147

    
148
class ResourceMetadata(models.Model):
149
    key = models.CharField(_('Name'), max_length=255, unique=True, db_index=True)
150
    value = models.CharField(_('Value'), max_length=255)
151

    
152
_presentation_data = {}
153
def get_presentation(resource):
154
    global _presentation_data
155
    presentation = _presentation_data.get(resource, {})
156
    if not presentation:
157
        resource_presentation = RESOURCES_PRESENTATION_DATA.get('resources', {})
158
        presentation = resource_presentation.get(resource, {})
159
        _presentation_data[resource] = presentation
160
    return presentation
161

    
162
class Resource(models.Model):
163
    name = models.CharField(_('Name'), max_length=255)
164
    meta = models.ManyToManyField(ResourceMetadata)
165
    service = models.ForeignKey(Service)
166
    desc = models.TextField(_('Description'), null=True)
167
    unit = models.CharField(_('Name'), null=True, max_length=255)
168
    group = models.CharField(_('Group'), null=True, max_length=255)
169
    uplimit = intDecimalField(default=0)
170

    
171
    class Meta:
172
        unique_together = ("service", "name")
173

    
174
    def __str__(self):
175
        return '%s%s%s' % (self.service, RESOURCE_SEPARATOR, self.name)
176

    
177
    def full_name(self):
178
        return str(self)
179

    
180
    @property
181
    def help_text(self):
182
        return get_presentation(str(self)).get('help_text', '')
183

    
184
    @property
185
    def help_text_input_each(self):
186
        return get_presentation(str(self)).get('help_text_input_each', '')
187

    
188
    @property
189
    def is_abbreviation(self):
190
        return get_presentation(str(self)).get('is_abbreviation', False)
191

    
192
    @property
193
    def report_desc(self):
194
        return get_presentation(str(self)).get('report_desc', '')
195

    
196
    @property
197
    def placeholder(self):
198
        return get_presentation(str(self)).get('placeholder', '')
199

    
200
    @property
201
    def verbose_name(self):
202
        return get_presentation(str(self)).get('verbose_name', '')
203

    
204
    @property
205
    def display_name(self):
206
        name = self.verbose_name
207
        if self.is_abbreviation:
208
            name = name.upper()
209
        return name
210

    
211
    @property
212
    def pluralized_display_name(self):
213
        if not self.unit:
214
            return '%ss' % self.display_name
215
        return self.display_name
216

    
217
def load_service_resources():
218
    ss = []
219
    rs = []
220
    counter = 0
221
    for service_name, data in sorted(SERVICES.iteritems()):
222
        url = data.get('url')
223
        order = data.get('order', counter)
224
        counter = order + 1
225
        resources = data.get('resources') or ()
226
        service, created = Service.objects.get_or_create(
227
            name=service_name,
228
            defaults={'url': url, 'order': order}
229
        )
230
        if not created:
231
            service.url = url
232
            service.save()
233

    
234
        ss.append(service)
235

    
236
        for resource in resources:
237
            try:
238
                resource_name = resource.pop('name', '')
239
                r, created = Resource.objects.get_or_create(
240
                        service=service, name=resource_name,
241
                        defaults=resource)
242
                if not created:
243
                    r.desc = resource['desc']
244
                    r.unit = resource.get('unit', None)
245
                    r.group = resource['group']
246
                    r.uplimit = resource['uplimit']
247
                    r.save()
248

    
249
                rs.append(r)
250

    
251
            except Exception, e:
252
                print "Cannot create resource ", resource_name
253
                import traceback; traceback.print_exc()
254
                continue
255

    
256
    register_services(ss)
257
    register_resources(rs)
258

    
259
def _quota_values(capacity):
260
    return QuotaValues(
261
        quantity = 0,
262
        capacity = capacity,
263
        import_limit = QH_PRACTICALLY_INFINITE,
264
        export_limit = QH_PRACTICALLY_INFINITE)
265

    
266
def get_default_quota():
267
    _DEFAULT_QUOTA = {}
268
    resources = Resource.objects.all()
269
    for resource in resources:
270
        capacity = resource.uplimit
271
        limits = _quota_values(capacity)
272
        _DEFAULT_QUOTA[resource.full_name()] = limits
273

    
274
    return _DEFAULT_QUOTA
275

    
276
def get_resource_names():
277
    _RESOURCE_NAMES = []
278
    resources = Resource.objects.all()
279
    _RESOURCE_NAMES = [resource.full_name() for resource in resources]
280
    return _RESOURCE_NAMES
281

    
282

    
283
class AstakosUserManager(UserManager):
284

    
285
    def get_auth_provider_user(self, provider, **kwargs):
286
        """
287
        Retrieve AstakosUser instance associated with the specified third party
288
        id.
289
        """
290
        kwargs = dict(map(lambda x: ('auth_providers__%s' % x[0], x[1]),
291
                          kwargs.iteritems()))
292
        return self.get(auth_providers__module=provider, **kwargs)
293

    
294
    def get_by_email(self, email):
295
        return self.get(email=email)
296

    
297
    def get_by_identifier(self, email_or_username, **kwargs):
298
        try:
299
            return self.get(email__iexact=email_or_username, **kwargs)
300
        except AstakosUser.DoesNotExist:
301
            return self.get(username__iexact=email_or_username, **kwargs)
302

    
303
    def user_exists(self, email_or_username, **kwargs):
304
        qemail = Q(email__iexact=email_or_username)
305
        qusername = Q(username__iexact=email_or_username)
306
        qextra = Q(**kwargs)
307
        return self.filter((qemail | qusername) & qextra).exists()
308

    
309
    def verified_user_exists(self, email_or_username):
310
        return self.user_exists(email_or_username, email_verified=True)
311

    
312
    def verified(self):
313
        return self.filter(email_verified=True)
314

    
315
    def verified(self):
316
        return self.filter(email_verified=True)
317

    
318
    def uuid_catalog(self, l=None):
319
        """
320
        Returns a uuid to username mapping for the uuids appearing in l.
321
        If l is None returns the mapping for all existing users.
322
        """
323
        q = self.filter(uuid__in=l) if l != None else self
324
        return dict(q.values_list('uuid', 'username'))
325

    
326
    def displayname_catalog(self, l=None):
327
        """
328
        Returns a username to uuid mapping for the usernames appearing in l.
329
        If l is None returns the mapping for all existing users.
330
        """
331
        if l is not None:
332
            lmap = dict((x.lower(), x) for x in l)
333
            q = self.filter(username__in=lmap.keys())
334
            values = ((lmap[n], u) for n, u in q.values_list('username', 'uuid'))
335
        else:
336
            q = self
337
            values = self.values_list('username', 'uuid')
338
        return dict(values)
339

    
340

    
341

    
342
class AstakosUser(User):
343
    """
344
    Extends ``django.contrib.auth.models.User`` by defining additional fields.
345
    """
346
    affiliation = models.CharField(_('Affiliation'), max_length=255, blank=True,
347
                                   null=True)
348

    
349
    # DEPRECATED FIELDS: provider, third_party_identifier moved in
350
    #                    AstakosUserProvider model.
351
    provider = models.CharField(_('Provider'), max_length=255, blank=True,
352
                                null=True)
353
    # ex. screen_name for twitter, eppn for shibboleth
354
    third_party_identifier = models.CharField(_('Third-party identifier'),
355
                                              max_length=255, null=True,
356
                                              blank=True)
357

    
358

    
359
    #for invitations
360
    user_level = DEFAULT_USER_LEVEL
361
    level = models.IntegerField(_('Inviter level'), default=user_level)
362
    invitations = models.IntegerField(
363
        _('Invitations left'), default=INVITATIONS_PER_LEVEL.get(user_level, 0))
364

    
365
    auth_token = models.CharField(_('Authentication Token'), max_length=32,
366
                  null=True, blank=True, help_text=_('Renew authentication token'))
367
    auth_token_created = models.DateTimeField(_('Token creation date'), null=True)
368
    auth_token_expires = models.DateTimeField(
369
        _('Token expiration date'), null=True)
370

    
371
    updated = models.DateTimeField(_('Update date'))
372
    is_verified = models.BooleanField(_('Is verified?'), default=False)
373

    
374
    email_verified = models.BooleanField(_('Email verified?'), default=False)
375

    
376
    has_credits = models.BooleanField(_('Has credits?'), default=False)
377
    has_signed_terms = models.BooleanField(
378
        _('I agree with the terms'), default=False)
379
    date_signed_terms = models.DateTimeField(
380
        _('Signed terms date'), null=True, blank=True)
381

    
382
    activation_sent = models.DateTimeField(
383
        _('Activation sent data'), null=True, blank=True)
384

    
385
    policy = models.ManyToManyField(
386
        Resource, null=True, through='AstakosUserQuota')
387

    
388
    uuid = models.CharField(max_length=255, null=True, blank=False, unique=True)
389

    
390
    __has_signed_terms = False
391
    disturbed_quota = models.BooleanField(_('Needs quotaholder syncing'),
392
                                           default=False, db_index=True)
393

    
394
    objects = AstakosUserManager()
395

    
396
    def __init__(self, *args, **kwargs):
397
        super(AstakosUser, self).__init__(*args, **kwargs)
398
        self.__has_signed_terms = self.has_signed_terms
399
        if not self.id:
400
            self.is_active = False
401

    
402
    @property
403
    def realname(self):
404
        return '%s %s' % (self.first_name, self.last_name)
405

    
406
    @realname.setter
407
    def realname(self, value):
408
        parts = value.split(' ')
409
        if len(parts) == 2:
410
            self.first_name = parts[0]
411
            self.last_name = parts[1]
412
        else:
413
            self.last_name = parts[0]
414

    
415
    def add_permission(self, pname):
416
        if self.has_perm(pname):
417
            return
418
        p, created = Permission.objects.get_or_create(
419
                                    codename=pname,
420
                                    name=pname.capitalize(),
421
                                    content_type=get_content_type())
422
        self.user_permissions.add(p)
423

    
424
    def remove_permission(self, pname):
425
        if self.has_perm(pname):
426
            return
427
        p = Permission.objects.get(codename=pname,
428
                                   content_type=get_content_type())
429
        self.user_permissions.remove(p)
430

    
431
    @property
432
    def invitation(self):
433
        try:
434
            return Invitation.objects.get(username=self.email)
435
        except Invitation.DoesNotExist:
436
            return None
437

    
438
    def initial_quotas(self):
439
        quotas = dict(get_default_quota())
440
        for user_quota in self.policies:
441
            resource = user_quota.resource.full_name()
442
            quotas[resource] = user_quota.quota_values()
443
        return quotas
444

    
445
    def all_quotas(self):
446
        quotas = self.initial_quotas()
447

    
448
        objects = self.projectmembership_set.select_related()
449
        memberships = objects.filter(is_active=True)
450
        for membership in memberships:
451
            application = membership.application
452
            if application is None:
453
                m = _("missing application for active membership %s"
454
                      % (membership,))
455
                raise AssertionError(m)
456

    
457
            grants = application.projectresourcegrant_set.all()
458
            for grant in grants:
459
                resource = grant.resource.full_name()
460
                prev = quotas.get(resource, 0)
461
                new = add_quota_values(prev, grant.member_quota_values())
462
                quotas[resource] = new
463
        return quotas
464

    
465
    @property
466
    def policies(self):
467
        return self.astakosuserquota_set.select_related().all()
468

    
469
    @policies.setter
470
    def policies(self, policies):
471
        for p in policies:
472
            p.setdefault('resource', '')
473
            p.setdefault('capacity', 0)
474
            p.setdefault('quantity', 0)
475
            p.setdefault('import_limit', 0)
476
            p.setdefault('export_limit', 0)
477
            p.setdefault('update', True)
478
            self.add_resource_policy(**p)
479

    
480
    def add_resource_policy(
481
            self, resource, capacity, quantity, import_limit,
482
            export_limit, update=True):
483
        """Raises ObjectDoesNotExist, IntegrityError"""
484
        s, sep, r = resource.partition(RESOURCE_SEPARATOR)
485
        resource = Resource.objects.get(service__name=s, name=r)
486
        if update:
487
            AstakosUserQuota.objects.update_or_create(
488
                user=self, resource=resource, defaults={
489
                    'capacity':capacity,
490
                    'quantity': quantity,
491
                    'import_limit':import_limit,
492
                    'export_limit':export_limit})
493
        else:
494
            q = self.astakosuserquota_set
495
            q.create(
496
                resource=resource, capacity=capacity, quanity=quantity,
497
                import_limit=import_limit, export_limit=export_limit)
498

    
499
    def remove_resource_policy(self, service, resource):
500
        """Raises ObjectDoesNotExist, IntegrityError"""
501
        resource = Resource.objects.get(service__name=service, name=resource)
502
        q = self.policies.get(resource=resource).delete()
503

    
504
    def update_uuid(self):
505
        while not self.uuid:
506
            uuid_val =  str(uuid.uuid4())
507
            try:
508
                AstakosUser.objects.get(uuid=uuid_val)
509
            except AstakosUser.DoesNotExist, e:
510
                self.uuid = uuid_val
511
        return self.uuid
512

    
513
    def save(self, update_timestamps=True, **kwargs):
514
        if update_timestamps:
515
            if not self.id:
516
                self.date_joined = datetime.now()
517
            self.updated = datetime.now()
518

    
519
        # update date_signed_terms if necessary
520
        if self.__has_signed_terms != self.has_signed_terms:
521
            self.date_signed_terms = datetime.now()
522

    
523
        self.update_uuid()
524

    
525
        if self.username != self.email.lower():
526
            # set username
527
            self.username = self.email.lower()
528

    
529
        super(AstakosUser, self).save(**kwargs)
530

    
531
    def renew_token(self, flush_sessions=False, current_key=None):
532
        md5 = hashlib.md5()
533
        md5.update(settings.SECRET_KEY)
534
        md5.update(self.username)
535
        md5.update(self.realname.encode('ascii', 'ignore'))
536
        md5.update(asctime())
537

    
538
        self.auth_token = b64encode(md5.digest())
539
        self.auth_token_created = datetime.now()
540
        self.auth_token_expires = self.auth_token_created + \
541
                                  timedelta(hours=AUTH_TOKEN_DURATION)
542
        if flush_sessions:
543
            self.flush_sessions(current_key)
544
        msg = 'Token renewed for %s' % self.email
545
        logger.log(LOGGING_LEVEL, msg)
546

    
547
    def flush_sessions(self, current_key=None):
548
        q = self.sessions
549
        if current_key:
550
            q = q.exclude(session_key=current_key)
551

    
552
        keys = q.values_list('session_key', flat=True)
553
        if keys:
554
            msg = 'Flushing sessions: %s' % ','.join(keys)
555
            logger.log(LOGGING_LEVEL, msg, [])
556
        engine = import_module(settings.SESSION_ENGINE)
557
        for k in keys:
558
            s = engine.SessionStore(k)
559
            s.flush()
560

    
561
    def __unicode__(self):
562
        return '%s (%s)' % (self.realname, self.email)
563

    
564
    def conflicting_email(self):
565
        q = AstakosUser.objects.exclude(username=self.username)
566
        q = q.filter(email__iexact=self.email)
567
        if q.count() != 0:
568
            return True
569
        return False
570

    
571
    def email_change_is_pending(self):
572
        return self.emailchanges.count() > 0
573

    
574
    @property
575
    def signed_terms(self):
576
        term = get_latest_terms()
577
        if not term:
578
            return True
579
        if not self.has_signed_terms:
580
            return False
581
        if not self.date_signed_terms:
582
            return False
583
        if self.date_signed_terms < term.date:
584
            self.has_signed_terms = False
585
            self.date_signed_terms = None
586
            self.save()
587
            return False
588
        return True
589

    
590
    def set_invitations_level(self):
591
        """
592
        Update user invitation level
593
        """
594
        level = self.invitation.inviter.level + 1
595
        self.level = level
596
        self.invitations = INVITATIONS_PER_LEVEL.get(level, 0)
597

    
598
    def can_login_with_auth_provider(self, provider):
599
        if not self.has_auth_provider(provider):
600
            return False
601
        else:
602
            return auth_providers.get_provider(provider).is_available_for_login()
603

    
604
    def can_add_auth_provider(self, provider, include_unverified=False, **kwargs):
605
        provider_settings = auth_providers.get_provider(provider)
606

    
607
        if not provider_settings.is_available_for_add():
608
            return False
609

    
610
        if self.has_auth_provider(provider) and \
611
           provider_settings.one_per_user:
612
            return False
613

    
614
        if 'provider_info' in kwargs:
615
            kwargs.pop('provider_info')
616

    
617
        if 'identifier' in kwargs:
618
            try:
619
                # provider with specified params already exist
620
                if not include_unverified:
621
                    kwargs['user__email_verified'] = True
622
                existing_user = AstakosUser.objects.get_auth_provider_user(provider,
623
                                                                   **kwargs)
624
            except AstakosUser.DoesNotExist:
625
                return True
626
            else:
627
                return False
628

    
629
        return True
630

    
631
    def can_remove_auth_provider(self, module):
632
        provider = auth_providers.get_provider(module)
633
        existing = self.get_active_auth_providers()
634
        existing_for_provider = self.get_active_auth_providers(module=module)
635

    
636
        if len(existing) <= 1:
637
            return False
638

    
639
        if len(existing_for_provider) == 1 and provider.is_required():
640
            return False
641

    
642
        return provider.is_available_for_remove()
643

    
644
    def can_change_password(self):
645
        return self.has_auth_provider('local', auth_backend='astakos')
646

    
647
    def can_change_email(self):
648
        non_astakos_local = self.get_auth_providers().filter(module='local')
649
        non_astakos_local = non_astakos_local.exclude(auth_backend='astakos')
650
        return non_astakos_local.count() == 0
651

    
652
    def has_required_auth_providers(self):
653
        required = auth_providers.REQUIRED_PROVIDERS
654
        for provider in required:
655
            if not self.has_auth_provider(provider):
656
                return False
657
        return True
658

    
659
    def has_auth_provider(self, provider, **kwargs):
660
        return bool(self.get_auth_providers().filter(module=provider,
661
                                               **kwargs).count())
662

    
663
    def add_auth_provider(self, provider, **kwargs):
664
        info_data = ''
665
        if 'provider_info' in kwargs:
666
            info_data = kwargs.pop('provider_info')
667
            if isinstance(info_data, dict):
668
                info_data = json.dumps(info_data)
669

    
670
        if self.can_add_auth_provider(provider, **kwargs):
671
            if 'identifier' in kwargs:
672
                # clean up third party pending for activation users of the same
673
                # identifier
674
                AstakosUserAuthProvider.objects.remove_unverified_providers(provider,
675
                                                                **kwargs)
676
            self.auth_providers.create(module=provider, active=True,
677
                                       info_data=info_data,
678
                                       **kwargs)
679
        else:
680
            raise Exception('Cannot add provider')
681

    
682
    def add_pending_auth_provider(self, pending):
683
        """
684
        Convert PendingThirdPartyUser object to AstakosUserAuthProvider entry for
685
        the current user.
686
        """
687
        if not isinstance(pending, PendingThirdPartyUser):
688
            pending = PendingThirdPartyUser.objects.get(token=pending)
689

    
690
        provider = self.add_auth_provider(pending.provider,
691
                               identifier=pending.third_party_identifier,
692
                                affiliation=pending.affiliation,
693
                                          provider_info=pending.info)
694

    
695
        if email_re.match(pending.email or '') and pending.email != self.email:
696
            self.additionalmail_set.get_or_create(email=pending.email)
697

    
698
        pending.delete()
699
        return provider
700

    
701
    def remove_auth_provider(self, provider, **kwargs):
702
        self.get_auth_providers().get(module=provider, **kwargs).delete()
703

    
704
    # user urls
705
    def get_resend_activation_url(self):
706
        return reverse('send_activation', kwargs={'user_id': self.pk})
707

    
708
    def get_provider_remove_url(self, module, **kwargs):
709
        return reverse('remove_auth_provider', kwargs={
710
            'pk': self.get_auth_providers().get(module=module, **kwargs).pk})
711

    
712
    def get_activation_url(self, nxt=False):
713
        url = "%s?auth=%s" % (reverse('astakos.im.views.activate'),
714
                                 quote(self.auth_token))
715
        if nxt:
716
            url += "&next=%s" % quote(nxt)
717
        return url
718

    
719
    def get_password_reset_url(self, token_generator=default_token_generator):
720
        return reverse('django.contrib.auth.views.password_reset_confirm',
721
                          kwargs={'uidb36':int_to_base36(self.id),
722
                                  'token':token_generator.make_token(self)})
723

    
724
    def get_auth_providers(self):
725
        return self.auth_providers
726

    
727
    def get_available_auth_providers(self):
728
        """
729
        Returns a list of providers available for user to connect to.
730
        """
731
        providers = []
732
        for module, provider_settings in auth_providers.PROVIDERS.iteritems():
733
            if self.can_add_auth_provider(module):
734
                providers.append(provider_settings(self))
735

    
736
        modules = astakos_settings.IM_MODULES
737
        def key(p):
738
            if not p.module in modules:
739
                return 100
740
            return modules.index(p.module)
741
        providers = sorted(providers, key=key)
742
        return providers
743

    
744
    def get_active_auth_providers(self, **filters):
745
        providers = []
746
        for provider in self.get_auth_providers().active(**filters):
747
            if auth_providers.get_provider(provider.module).is_available_for_login():
748
                providers.append(provider)
749

    
750
        modules = astakos_settings.IM_MODULES
751
        def key(p):
752
            if not p.module in modules:
753
                return 100
754
            return modules.index(p.module)
755
        providers = sorted(providers, key=key)
756
        return providers
757

    
758
    @property
759
    def auth_providers_display(self):
760
        return ",".join(map(lambda x:unicode(x), self.get_auth_providers().active()))
761

    
762
    def get_inactive_message(self):
763
        msg_extra = ''
764
        message = ''
765
        if self.activation_sent:
766
            if self.email_verified:
767
                message = _(astakos_messages.ACCOUNT_INACTIVE)
768
            else:
769
                message = _(astakos_messages.ACCOUNT_PENDING_ACTIVATION)
770
                if astakos_settings.MODERATION_ENABLED:
771
                    msg_extra = _(astakos_messages.ACCOUNT_PENDING_ACTIVATION_HELP)
772
                else:
773
                    url = self.get_resend_activation_url()
774
                    msg_extra = mark_safe(_(astakos_messages.ACCOUNT_PENDING_ACTIVATION_HELP) + \
775
                                u' ' + \
776
                                _('<a href="%s">%s?</a>') % (url,
777
                                _(astakos_messages.ACCOUNT_RESEND_ACTIVATION_PROMPT)))
778
        else:
779
            if astakos_settings.MODERATION_ENABLED:
780
                message = _(astakos_messages.ACCOUNT_PENDING_MODERATION)
781
            else:
782
                message = astakos_messages.ACCOUNT_PENDING_ACTIVATION
783
                url = self.get_resend_activation_url()
784
                msg_extra = mark_safe(_('<a href="%s">%s?</a>') % (url,
785
                            _(astakos_messages.ACCOUNT_RESEND_ACTIVATION_PROMPT)))
786

    
787
        return mark_safe(message + u' '+ msg_extra)
788

    
789
    def owns_application(self, application):
790
        return application.owner == self
791

    
792
    def owns_project(self, project):
793
        return project.application.owner == self
794

    
795
    def is_associated(self, project):
796
        try:
797
            m = ProjectMembership.objects.get(person=self, project=project)
798
            return m.state in ProjectMembership.ASSOCIATED_STATES
799
        except ProjectMembership.DoesNotExist:
800
            return False
801

    
802
    def get_membership(self, project):
803
        try:
804
            return ProjectMembership.objects.get(
805
                project=project,
806
                person=self)
807
        except ProjectMembership.DoesNotExist:
808
            return None
809

    
810
    def membership_display(self, project):
811
        m = self.get_membership(project)
812
        if m is None:
813
            return _('Not a member')
814
        else:
815
            return m.user_friendly_state_display()
816

    
817
    def non_owner_can_view(self, maybe_project):
818
        if maybe_project is None:
819
            return False
820
        project = maybe_project
821
        if self.is_associated(project):
822
            return True
823
        if project.is_deactivated():
824
            return False
825
        return True
826

    
827

    
828
class AstakosUserAuthProviderManager(models.Manager):
829

    
830
    def active(self, **filters):
831
        return self.filter(active=True, **filters)
832

    
833
    def remove_unverified_providers(self, provider, **filters):
834
        try:
835
            existing = self.filter(module=provider, user__email_verified=False, **filters)
836
            for p in existing:
837
                p.user.delete()
838
        except:
839
            pass
840

    
841

    
842

    
843
class AstakosUserAuthProvider(models.Model):
844
    """
845
    Available user authentication methods.
846
    """
847
    affiliation = models.CharField(_('Affiliation'), max_length=255, blank=True,
848
                                   null=True, default=None)
849
    user = models.ForeignKey(AstakosUser, related_name='auth_providers')
850
    module = models.CharField(_('Provider'), max_length=255, blank=False,
851
                                default='local')
852
    identifier = models.CharField(_('Third-party identifier'),
853
                                              max_length=255, null=True,
854
                                              blank=True)
855
    active = models.BooleanField(default=True)
856
    auth_backend = models.CharField(_('Backend'), max_length=255, blank=False,
857
                                   default='astakos')
858
    info_data = models.TextField(default="", null=True, blank=True)
859
    created = models.DateTimeField('Creation date', auto_now_add=True)
860

    
861
    objects = AstakosUserAuthProviderManager()
862

    
863
    class Meta:
864
        unique_together = (('identifier', 'module', 'user'), )
865
        ordering = ('module', 'created')
866

    
867
    def __init__(self, *args, **kwargs):
868
        super(AstakosUserAuthProvider, self).__init__(*args, **kwargs)
869
        try:
870
            self.info = json.loads(self.info_data)
871
            if not self.info:
872
                self.info = {}
873
        except Exception, e:
874
            self.info = {}
875

    
876
        for key,value in self.info.iteritems():
877
            setattr(self, 'info_%s' % key, value)
878

    
879

    
880
    @property
881
    def settings(self):
882
        return auth_providers.get_provider(self.module)
883

    
884
    @property
885
    def details_display(self):
886
        try:
887
            params = self.user.__dict__
888
            params.update(self.__dict__)
889
            return self.settings.get_details_tpl_display % params
890
        except:
891
            return ''
892

    
893
    @property
894
    def title_display(self):
895
        title_tpl = self.settings.get_title_display
896
        try:
897
            if self.settings.get_user_title_display:
898
                title_tpl = self.settings.get_user_title_display
899
        except Exception, e:
900
            pass
901
        try:
902
          return title_tpl % self.__dict__
903
        except:
904
          return self.settings.get_title_display % self.__dict__
905

    
906
    def can_remove(self):
907
        return self.user.can_remove_auth_provider(self.module)
908

    
909
    def delete(self, *args, **kwargs):
910
        ret = super(AstakosUserAuthProvider, self).delete(*args, **kwargs)
911
        if self.module == 'local':
912
            self.user.set_unusable_password()
913
            self.user.save()
914
        return ret
915

    
916
    def __repr__(self):
917
        return '<AstakosUserAuthProvider %s:%s>' % (self.module, self.identifier)
918

    
919
    def __unicode__(self):
920
        if self.identifier:
921
            return "%s:%s" % (self.module, self.identifier)
922
        if self.auth_backend:
923
            return "%s:%s" % (self.module, self.auth_backend)
924
        return self.module
925

    
926
    def save(self, *args, **kwargs):
927
        self.info_data = json.dumps(self.info)
928
        return super(AstakosUserAuthProvider, self).save(*args, **kwargs)
929

    
930

    
931
class ExtendedManager(models.Manager):
932
    def _update_or_create(self, **kwargs):
933
        assert kwargs, \
934
            'update_or_create() must be passed at least one keyword argument'
935
        obj, created = self.get_or_create(**kwargs)
936
        defaults = kwargs.pop('defaults', {})
937
        if created:
938
            return obj, True, False
939
        else:
940
            try:
941
                params = dict(
942
                    [(k, v) for k, v in kwargs.items() if '__' not in k])
943
                params.update(defaults)
944
                for attr, val in params.items():
945
                    if hasattr(obj, attr):
946
                        setattr(obj, attr, val)
947
                sid = transaction.savepoint()
948
                obj.save(force_update=True)
949
                transaction.savepoint_commit(sid)
950
                return obj, False, True
951
            except IntegrityError, e:
952
                transaction.savepoint_rollback(sid)
953
                try:
954
                    return self.get(**kwargs), False, False
955
                except self.model.DoesNotExist:
956
                    raise e
957

    
958
    update_or_create = _update_or_create
959

    
960

    
961
class AstakosUserQuota(models.Model):
962
    objects = ExtendedManager()
963
    capacity = intDecimalField()
964
    quantity = intDecimalField(default=0)
965
    export_limit = intDecimalField(default=QH_PRACTICALLY_INFINITE)
966
    import_limit = intDecimalField(default=QH_PRACTICALLY_INFINITE)
967
    resource = models.ForeignKey(Resource)
968
    user = models.ForeignKey(AstakosUser)
969

    
970
    class Meta:
971
        unique_together = ("resource", "user")
972

    
973
    def quota_values(self):
974
        return QuotaValues(
975
            quantity = self.quantity,
976
            capacity = self.capacity,
977
            import_limit = self.import_limit,
978
            export_limit = self.export_limit)
979

    
980

    
981
class ApprovalTerms(models.Model):
982
    """
983
    Model for approval terms
984
    """
985

    
986
    date = models.DateTimeField(
987
        _('Issue date'), db_index=True, auto_now_add=True)
988
    location = models.CharField(_('Terms location'), max_length=255)
989

    
990

    
991
class Invitation(models.Model):
992
    """
993
    Model for registring invitations
994
    """
995
    inviter = models.ForeignKey(AstakosUser, related_name='invitations_sent',
996
                                null=True)
997
    realname = models.CharField(_('Real name'), max_length=255)
998
    username = models.CharField(_('Unique ID'), max_length=255, unique=True)
999
    code = models.BigIntegerField(_('Invitation code'), db_index=True)
1000
    is_consumed = models.BooleanField(_('Consumed?'), default=False)
1001
    created = models.DateTimeField(_('Creation date'), auto_now_add=True)
1002
    consumed = models.DateTimeField(_('Consumption date'), null=True, blank=True)
1003

    
1004
    def __init__(self, *args, **kwargs):
1005
        super(Invitation, self).__init__(*args, **kwargs)
1006
        if not self.id:
1007
            self.code = _generate_invitation_code()
1008

    
1009
    def consume(self):
1010
        self.is_consumed = True
1011
        self.consumed = datetime.now()
1012
        self.save()
1013

    
1014
    def __unicode__(self):
1015
        return '%s -> %s [%d]' % (self.inviter, self.username, self.code)
1016

    
1017

    
1018
class EmailChangeManager(models.Manager):
1019

    
1020
    @transaction.commit_on_success
1021
    def change_email(self, activation_key):
1022
        """
1023
        Validate an activation key and change the corresponding
1024
        ``User`` if valid.
1025

1026
        If the key is valid and has not expired, return the ``User``
1027
        after activating.
1028

1029
        If the key is not valid or has expired, return ``None``.
1030

1031
        If the key is valid but the ``User`` is already active,
1032
        return ``None``.
1033

1034
        After successful email change the activation record is deleted.
1035

1036
        Throws ValueError if there is already
1037
        """
1038
        try:
1039
            email_change = self.model.objects.get(
1040
                activation_key=activation_key)
1041
            if email_change.activation_key_expired():
1042
                email_change.delete()
1043
                raise EmailChange.DoesNotExist
1044
            # is there an active user with this address?
1045
            try:
1046
                AstakosUser.objects.get(email__iexact=email_change.new_email_address)
1047
            except AstakosUser.DoesNotExist:
1048
                pass
1049
            else:
1050
                raise ValueError(_('The new email address is reserved.'))
1051
            # update user
1052
            user = AstakosUser.objects.get(pk=email_change.user_id)
1053
            old_email = user.email
1054
            user.email = email_change.new_email_address
1055
            user.save()
1056
            email_change.delete()
1057
            msg = "User %d changed email from %s to %s" % (user.pk, old_email,
1058
                                                          user.email)
1059
            logger.log(LOGGING_LEVEL, msg)
1060
            return user
1061
        except EmailChange.DoesNotExist:
1062
            raise ValueError(_('Invalid activation key.'))
1063

    
1064

    
1065
class EmailChange(models.Model):
1066
    new_email_address = models.EmailField(
1067
        _(u'new e-mail address'),
1068
        help_text=_('Provide a new email address. Until you verify the new '
1069
                    'address by following the activation link that will be '
1070
                    'sent to it, your old email address will remain active.'))
1071
    user = models.ForeignKey(
1072
        AstakosUser, unique=True, related_name='emailchanges')
1073
    requested_at = models.DateTimeField(auto_now_add=True)
1074
    activation_key = models.CharField(
1075
        max_length=40, unique=True, db_index=True)
1076

    
1077
    objects = EmailChangeManager()
1078

    
1079
    def get_url(self):
1080
        return reverse('email_change_confirm',
1081
                      kwargs={'activation_key': self.activation_key})
1082

    
1083
    def activation_key_expired(self):
1084
        expiration_date = timedelta(days=EMAILCHANGE_ACTIVATION_DAYS)
1085
        return self.requested_at + expiration_date < datetime.now()
1086

    
1087

    
1088
class AdditionalMail(models.Model):
1089
    """
1090
    Model for registring invitations
1091
    """
1092
    owner = models.ForeignKey(AstakosUser)
1093
    email = models.EmailField()
1094

    
1095

    
1096
def _generate_invitation_code():
1097
    while True:
1098
        code = randint(1, 2L ** 63 - 1)
1099
        try:
1100
            Invitation.objects.get(code=code)
1101
            # An invitation with this code already exists, try again
1102
        except Invitation.DoesNotExist:
1103
            return code
1104

    
1105

    
1106
def get_latest_terms():
1107
    try:
1108
        term = ApprovalTerms.objects.order_by('-id')[0]
1109
        return term
1110
    except IndexError:
1111
        pass
1112
    return None
1113

    
1114
class PendingThirdPartyUser(models.Model):
1115
    """
1116
    Model for registring successful third party user authentications
1117
    """
1118
    third_party_identifier = models.CharField(_('Third-party identifier'), max_length=255, null=True, blank=True)
1119
    provider = models.CharField(_('Provider'), max_length=255, blank=True)
1120
    email = models.EmailField(_('e-mail address'), blank=True, null=True)
1121
    first_name = models.CharField(_('first name'), max_length=30, blank=True,
1122
                                  null=True)
1123
    last_name = models.CharField(_('last name'), max_length=30, blank=True,
1124
                                 null=True)
1125
    affiliation = models.CharField('Affiliation', max_length=255, blank=True,
1126
                                   null=True)
1127
    username = models.CharField(_('username'), max_length=30, unique=True, help_text=_("Required. 30 characters or fewer. Letters, numbers and @/./+/-/_ characters"))
1128
    token = models.CharField(_('Token'), max_length=255, null=True, blank=True)
1129
    created = models.DateTimeField(auto_now_add=True, null=True, blank=True)
1130
    info = models.TextField(default="", null=True, blank=True)
1131

    
1132
    class Meta:
1133
        unique_together = ("provider", "third_party_identifier")
1134

    
1135
    def get_user_instance(self):
1136
        d = self.__dict__
1137
        d.pop('_state', None)
1138
        d.pop('id', None)
1139
        d.pop('token', None)
1140
        d.pop('created', None)
1141
        d.pop('info', None)
1142
        user = AstakosUser(**d)
1143

    
1144
        return user
1145

    
1146
    @property
1147
    def realname(self):
1148
        return '%s %s' %(self.first_name, self.last_name)
1149

    
1150
    @realname.setter
1151
    def realname(self, value):
1152
        parts = value.split(' ')
1153
        if len(parts) == 2:
1154
            self.first_name = parts[0]
1155
            self.last_name = parts[1]
1156
        else:
1157
            self.last_name = parts[0]
1158

    
1159
    def save(self, **kwargs):
1160
        if not self.id:
1161
            # set username
1162
            while not self.username:
1163
                username =  uuid.uuid4().hex[:30]
1164
                try:
1165
                    AstakosUser.objects.get(username = username)
1166
                except AstakosUser.DoesNotExist, e:
1167
                    self.username = username
1168
        super(PendingThirdPartyUser, self).save(**kwargs)
1169

    
1170
    def generate_token(self):
1171
        self.password = self.third_party_identifier
1172
        self.last_login = datetime.now()
1173
        self.token = default_token_generator.make_token(self)
1174

    
1175
class SessionCatalog(models.Model):
1176
    session_key = models.CharField(_('session key'), max_length=40)
1177
    user = models.ForeignKey(AstakosUser, related_name='sessions', null=True)
1178

    
1179

    
1180
### PROJECTS ###
1181
################
1182

    
1183
def synced_model_metaclass(class_name, class_parents, class_attributes):
1184

    
1185
    new_attributes = {}
1186
    sync_attributes = {}
1187

    
1188
    for name, value in class_attributes.iteritems():
1189
        sync, underscore, rest = name.partition('_')
1190
        if sync == 'sync' and underscore == '_':
1191
            sync_attributes[rest] = value
1192
        else:
1193
            new_attributes[name] = value
1194

    
1195
    if 'prefix' not in sync_attributes:
1196
        m = ("you did not specify a 'sync_prefix' attribute "
1197
             "in class '%s'" % (class_name,))
1198
        raise ValueError(m)
1199

    
1200
    prefix = sync_attributes.pop('prefix')
1201
    class_name = sync_attributes.pop('classname', prefix + '_model')
1202

    
1203
    for name, value in sync_attributes.iteritems():
1204
        newname = prefix + '_' + name
1205
        if newname in new_attributes:
1206
            m = ("class '%s' was specified with prefix '%s' "
1207
                 "but it already has an attribute named '%s'"
1208
                 % (class_name, prefix, newname))
1209
            raise ValueError(m)
1210

    
1211
        new_attributes[newname] = value
1212

    
1213
    newclass = type(class_name, class_parents, new_attributes)
1214
    return newclass
1215

    
1216

    
1217
def make_synced(prefix='sync', name='SyncedState'):
1218

    
1219
    the_name = name
1220
    the_prefix = prefix
1221

    
1222
    class SyncedState(models.Model):
1223

    
1224
        sync_classname      = the_name
1225
        sync_prefix         = the_prefix
1226
        __metaclass__       = synced_model_metaclass
1227

    
1228
        sync_new_state      = models.BigIntegerField(null=True)
1229
        sync_synced_state   = models.BigIntegerField(null=True)
1230
        STATUS_SYNCED       = 0
1231
        STATUS_PENDING      = 1
1232
        sync_status         = models.IntegerField(db_index=True)
1233

    
1234
        class Meta:
1235
            abstract = True
1236

    
1237
        class NotSynced(Exception):
1238
            pass
1239

    
1240
        def sync_init_state(self, state):
1241
            self.sync_synced_state = state
1242
            self.sync_new_state = state
1243
            self.sync_status = self.STATUS_SYNCED
1244

    
1245
        def sync_get_status(self):
1246
            return self.sync_status
1247

    
1248
        def sync_set_status(self):
1249
            if self.sync_new_state != self.sync_synced_state:
1250
                self.sync_status = self.STATUS_PENDING
1251
            else:
1252
                self.sync_status = self.STATUS_SYNCED
1253

    
1254
        def sync_set_synced(self):
1255
            self.sync_synced_state = self.sync_new_state
1256
            self.sync_status = self.STATUS_SYNCED
1257

    
1258
        def sync_get_synced_state(self):
1259
            return self.sync_synced_state
1260

    
1261
        def sync_set_new_state(self, new_state):
1262
            self.sync_new_state = new_state
1263
            self.sync_set_status()
1264

    
1265
        def sync_get_new_state(self):
1266
            return self.sync_new_state
1267

    
1268
        def sync_set_synced_state(self, synced_state):
1269
            self.sync_synced_state = synced_state
1270
            self.sync_set_status()
1271

    
1272
        def sync_get_pending_objects(self):
1273
            kw = dict((the_prefix + '_status', self.STATUS_PENDING))
1274
            return self.objects.filter(**kw)
1275

    
1276
        def sync_get_synced_objects(self):
1277
            kw = dict((the_prefix + '_status', self.STATUS_SYNCED))
1278
            return self.objects.filter(**kw)
1279

    
1280
        def sync_verify_get_synced_state(self):
1281
            status = self.sync_get_status()
1282
            state = self.sync_get_synced_state()
1283
            verified = (status == self.STATUS_SYNCED)
1284
            return state, verified
1285

    
1286
        def sync_is_synced(self):
1287
            state, verified = self.sync_verify_get_synced_state()
1288
            return verified
1289

    
1290
    return SyncedState
1291

    
1292
SyncedState = make_synced(prefix='sync', name='SyncedState')
1293

    
1294

    
1295
class ChainManager(ForUpdateManager):
1296

    
1297
    def search_by_name(self, *search_strings):
1298
        projects = Project.objects.search_by_name(*search_strings)
1299
        chains = [p.id for p in projects]
1300
        apps  = ProjectApplication.objects.search_by_name(*search_strings)
1301
        apps = (app for app in apps if app.is_latest())
1302
        app_chains = [app.chain for app in apps if app.chain not in chains]
1303
        return chains + app_chains
1304

    
1305
    def all_full_state(self):
1306
        d = {}
1307
        chains = self.all()
1308
        for chain in chains:
1309
            d[chain.pk] = chain.full_state()
1310
        return d
1311

    
1312
    def of_project(self, project):
1313
        if project is None:
1314
            return None
1315
        try:
1316
            return self.get(chain=project.id)
1317
        except Chain.DoesNotExist:
1318
            raise AssertionError('project with no chain')
1319

    
1320

    
1321
class Chain(models.Model):
1322
    chain  =   models.AutoField(primary_key=True)
1323

    
1324
    def __str__(self):
1325
        return "%s" % (self.chain,)
1326

    
1327
    objects = ChainManager()
1328

    
1329
    PENDING            = 0
1330
    DENIED             = 3
1331
    DISMISSED          = 4
1332
    CANCELLED          = 5
1333

    
1334
    APPROVED           = 10
1335
    APPROVED_PENDING   = 11
1336
    SUSPENDED          = 12
1337
    SUSPENDED_PENDING  = 13
1338
    TERMINATED         = 14
1339
    TERMINATED_PENDING = 15
1340

    
1341
    PENDING_STATES = [PENDING,
1342
                      APPROVED_PENDING,
1343
                      SUSPENDED_PENDING,
1344
                      TERMINATED_PENDING,
1345
                      ]
1346

    
1347
    SKIP_STATES = [DISMISSED,
1348
                   CANCELLED,
1349
                   TERMINATED]
1350

    
1351
    STATE_DISPLAY = {
1352
        PENDING            : _("Pending"),
1353
        DENIED             : _("Denied"),
1354
        DISMISSED          : _("Dismissed"),
1355
        CANCELLED          : _("Cancelled"),
1356
        APPROVED           : _("Active"),
1357
        APPROVED_PENDING   : _("Active - Pending"),
1358
        SUSPENDED          : _("Suspended"),
1359
        SUSPENDED_PENDING  : _("Suspended - Pending"),
1360
        TERMINATED         : _("Terminated"),
1361
        TERMINATED_PENDING : _("Terminated - Pending"),
1362
        }
1363

    
1364

    
1365
    @classmethod
1366
    def _chain_state(cls, project_state, app_state):
1367
        s = CHAIN_STATE.get((project_state, app_state), None)
1368
        if s is None:
1369
            raise AssertionError('inconsistent chain state')
1370
        return s
1371

    
1372
    @classmethod
1373
    def chain_state(cls, project, app):
1374
        p_state = project.state if project else None
1375
        return cls._chain_state(p_state, app.state)
1376

    
1377
    @classmethod
1378
    def state_display(cls, s):
1379
        if s is None:
1380
            return _("Unknown")
1381
        return cls.STATE_DISPLAY.get(s, _("Inconsistent"))
1382

    
1383
    def last_application(self):
1384
        return self.chained_apps.order_by('-id')[0]
1385

    
1386
    def get_project(self):
1387
        try:
1388
            return self.chained_project
1389
        except Project.DoesNotExist:
1390
            return None
1391

    
1392
    def get_elements(self):
1393
        project = self.get_project()
1394
        app = self.last_application()
1395
        return project, app
1396

    
1397
    def full_state(self):
1398
        project, app = self.get_elements()
1399
        s = self.chain_state(project, app)
1400
        return s, project, app
1401

    
1402
def new_chain():
1403
    c = Chain.objects.create()
1404
    return c
1405

    
1406

    
1407
class ProjectApplicationManager(ForUpdateManager):
1408

    
1409
    def user_visible_projects(self, *filters, **kw_filters):
1410
        model = self.model
1411
        return self.filter(model.Q_PENDING | model.Q_APPROVED)
1412

    
1413
    def user_visible_by_chain(self, flt):
1414
        model = self.model
1415
        pending = self.filter(model.Q_PENDING | model.Q_DENIED).values_list('chain')
1416
        approved = self.filter(model.Q_APPROVED).values_list('chain')
1417
        by_chain = dict(pending.annotate(models.Max('id')))
1418
        by_chain.update(approved.annotate(models.Max('id')))
1419
        return self.filter(flt, id__in=by_chain.values())
1420

    
1421
    def user_accessible_projects(self, user):
1422
        """
1423
        Return projects accessed by specified user.
1424
        """
1425
        participates_filters = Q(owner=user) | Q(applicant=user) | \
1426
                               Q(project__projectmembership__person=user)
1427

    
1428
        return self.user_visible_by_chain(participates_filters).order_by('issue_date').distinct()
1429

    
1430
    def search_by_name(self, *search_strings):
1431
        q = Q()
1432
        for s in search_strings:
1433
            q = q | Q(name__icontains=s)
1434
        return self.filter(q)
1435

    
1436
    def latest_of_chain(self, chain_id):
1437
        try:
1438
            return self.filter(chain=chain_id).order_by('-id')[0]
1439
        except IndexError:
1440
            return None
1441

    
1442

    
1443
class ProjectApplication(models.Model):
1444
    applicant               =   models.ForeignKey(
1445
                                    AstakosUser,
1446
                                    related_name='projects_applied',
1447
                                    db_index=True)
1448

    
1449
    PENDING     =    0
1450
    APPROVED    =    1
1451
    REPLACED    =    2
1452
    DENIED      =    3
1453
    DISMISSED   =    4
1454
    CANCELLED   =    5
1455

    
1456
    state                   =   models.IntegerField(default=PENDING,
1457
                                                    db_index=True)
1458

    
1459
    owner                   =   models.ForeignKey(
1460
                                    AstakosUser,
1461
                                    related_name='projects_owned',
1462
                                    db_index=True)
1463

    
1464
    chain                   =   models.ForeignKey(Chain,
1465
                                                  related_name='chained_apps',
1466
                                                  db_column='chain')
1467
    precursor_application   =   models.ForeignKey('ProjectApplication',
1468
                                                  null=True,
1469
                                                  blank=True)
1470

    
1471
    name                    =   models.CharField(max_length=80)
1472
    homepage                =   models.URLField(max_length=255, null=True,
1473
                                                verify_exists=False)
1474
    description             =   models.TextField(null=True, blank=True)
1475
    start_date              =   models.DateTimeField(null=True, blank=True)
1476
    end_date                =   models.DateTimeField()
1477
    member_join_policy      =   models.IntegerField()
1478
    member_leave_policy     =   models.IntegerField()
1479
    limit_on_members_number =   models.PositiveIntegerField(null=True)
1480
    resource_grants         =   models.ManyToManyField(
1481
                                    Resource,
1482
                                    null=True,
1483
                                    blank=True,
1484
                                    through='ProjectResourceGrant')
1485
    comments                =   models.TextField(null=True, blank=True)
1486
    issue_date              =   models.DateTimeField(auto_now_add=True)
1487
    response_date           =   models.DateTimeField(null=True, blank=True)
1488

    
1489
    objects                 =   ProjectApplicationManager()
1490

    
1491
    # Compiled queries
1492
    Q_PENDING  = Q(state=PENDING)
1493
    Q_APPROVED = Q(state=APPROVED)
1494
    Q_DENIED   = Q(state=DENIED)
1495

    
1496
    class Meta:
1497
        unique_together = ("chain", "id")
1498

    
1499
    def __unicode__(self):
1500
        return "%s applied by %s" % (self.name, self.applicant)
1501

    
1502
    # TODO: Move to a more suitable place
1503
    APPLICATION_STATE_DISPLAY = {
1504
        PENDING  : _('Pending review'),
1505
        APPROVED : _('Approved'),
1506
        REPLACED : _('Replaced'),
1507
        DENIED   : _('Denied'),
1508
        DISMISSED: _('Dismissed'),
1509
        CANCELLED: _('Cancelled')
1510
    }
1511

    
1512
    def get_project(self):
1513
        try:
1514
            project = Project.objects.get(id=self.chain, state=Project.APPROVED)
1515
            return Project
1516
        except Project.DoesNotExist, e:
1517
            return None
1518

    
1519
    def state_display(self):
1520
        return self.APPLICATION_STATE_DISPLAY.get(self.state, _('Unknown'))
1521

    
1522
    def project_state_display(self):
1523
        try:
1524
            project = self.project
1525
            return project.state_display()
1526
        except Project.DoesNotExist:
1527
            return self.state_display()
1528

    
1529
    def add_resource_policy(self, service, resource, uplimit):
1530
        """Raises ObjectDoesNotExist, IntegrityError"""
1531
        q = self.projectresourcegrant_set
1532
        resource = Resource.objects.get(service__name=service, name=resource)
1533
        q.create(resource=resource, member_capacity=uplimit)
1534

    
1535
    def members_count(self):
1536
        return self.project.approved_memberships.count()
1537

    
1538
    @property
1539
    def grants(self):
1540
        return self.projectresourcegrant_set.values(
1541
            'member_capacity', 'resource__name', 'resource__service__name')
1542

    
1543
    @property
1544
    def resource_policies(self):
1545
        return [str(rp) for rp in self.projectresourcegrant_set.all()]
1546

    
1547
    @resource_policies.setter
1548
    def resource_policies(self, policies):
1549
        for p in policies:
1550
            service = p.get('service', None)
1551
            resource = p.get('resource', None)
1552
            uplimit = p.get('uplimit', 0)
1553
            self.add_resource_policy(service, resource, uplimit)
1554

    
1555
    def pending_modifications_incl_me(self):
1556
        q = self.chained_applications()
1557
        q = q.filter(Q(state=self.PENDING))
1558
        return q
1559

    
1560
    def last_pending_incl_me(self):
1561
        try:
1562
            return self.pending_modifications_incl_me().order_by('-id')[0]
1563
        except IndexError:
1564
            return None
1565

    
1566
    def pending_modifications(self):
1567
        return self.pending_modifications_incl_me().filter(~Q(id=self.id))
1568

    
1569
    def last_pending(self):
1570
        try:
1571
            return self.pending_modifications().order_by('-id')[0]
1572
        except IndexError:
1573
            return None
1574

    
1575
    def is_modification(self):
1576
        # if self.state != self.PENDING:
1577
        #     return False
1578
        parents = self.chained_applications().filter(id__lt=self.id)
1579
        parents = parents.filter(state__in=[self.APPROVED])
1580
        return parents.count() > 0
1581

    
1582
    def chained_applications(self):
1583
        return ProjectApplication.objects.filter(chain=self.chain)
1584

    
1585
    def is_latest(self):
1586
        return self.chained_applications().order_by('-id')[0] == self
1587

    
1588
    def has_pending_modifications(self):
1589
        return bool(self.last_pending())
1590

    
1591
    def is_applied(self):
1592
        try:
1593
            self.project
1594
            return True
1595
        except Project.DoesNotExist:
1596
            return False
1597

    
1598
    def get_project(self):
1599
        try:
1600
            return Project.objects.get(id=self.chain)
1601
        except Project.DoesNotExist:
1602
            return None
1603

    
1604
    def project_exists(self):
1605
        return self.get_project() is not None
1606

    
1607
    def _get_project_for_update(self):
1608
        try:
1609
            objects = Project.objects.select_for_update()
1610
            project = objects.get(id=self.chain)
1611
            return project
1612
        except Project.DoesNotExist:
1613
            return None
1614

    
1615
    def can_cancel(self):
1616
        return self.state == self.PENDING
1617

    
1618
    def cancel(self):
1619
        if not self.can_cancel():
1620
            m = _("cannot cancel: application '%s' in state '%s'") % (
1621
                    self.id, self.state)
1622
            raise AssertionError(m)
1623

    
1624
        self.state = self.CANCELLED
1625
        self.save()
1626

    
1627
    def can_dismiss(self):
1628
        return self.state == self.DENIED
1629

    
1630
    def dismiss(self):
1631
        if not self.can_dismiss():
1632
            m = _("cannot dismiss: application '%s' in state '%s'") % (
1633
                    self.id, self.state)
1634
            raise AssertionError(m)
1635

    
1636
        self.state = self.DISMISSED
1637
        self.save()
1638

    
1639
    def can_deny(self):
1640
        return self.state == self.PENDING
1641

    
1642
    def deny(self):
1643
        if not self.can_deny():
1644
            m = _("cannot deny: application '%s' in state '%s'") % (
1645
                    self.id, self.state)
1646
            raise AssertionError(m)
1647

    
1648
        self.state = self.DENIED
1649
        self.response_date = datetime.now()
1650
        self.save()
1651

    
1652
    def can_approve(self):
1653
        return self.state == self.PENDING
1654

    
1655
    def approve(self, approval_user=None):
1656
        """
1657
        If approval_user then during owner membership acceptance
1658
        it is checked whether the request_user is eligible.
1659

1660
        Raises:
1661
            PermissionDenied
1662
        """
1663

    
1664
        if not transaction.is_managed():
1665
            raise AssertionError("NOPE")
1666

    
1667
        new_project_name = self.name
1668
        if not self.can_approve():
1669
            m = _("cannot approve: project '%s' in state '%s'") % (
1670
                    new_project_name, self.state)
1671
            raise AssertionError(m) # invalid argument
1672

    
1673
        now = datetime.now()
1674
        project = self._get_project_for_update()
1675

    
1676
        try:
1677
            q = Q(name=new_project_name) & ~Q(state=Project.TERMINATED)
1678
            conflicting_project = Project.objects.get(q)
1679
            if (conflicting_project != project):
1680
                m = (_("cannot approve: project with name '%s' "
1681
                       "already exists (id: %s)") % (
1682
                        new_project_name, conflicting_project.id))
1683
                raise PermissionDenied(m) # invalid argument
1684
        except Project.DoesNotExist:
1685
            pass
1686

    
1687
        new_project = False
1688
        if project is None:
1689
            new_project = True
1690
            project = Project(id=self.chain)
1691

    
1692
        project.name = new_project_name
1693
        project.application = self
1694
        project.last_approval_date = now
1695
        if not new_project:
1696
            project.is_modified = True
1697

    
1698
        project.save()
1699

    
1700
        self.state = self.APPROVED
1701
        self.response_date = now
1702
        self.save()
1703

    
1704
    @property
1705
    def member_join_policy_display(self):
1706
        return PROJECT_MEMBER_JOIN_POLICIES.get(str(self.member_join_policy))
1707

    
1708
    @property
1709
    def member_leave_policy_display(self):
1710
        return PROJECT_MEMBER_LEAVE_POLICIES.get(str(self.member_leave_policy))
1711

    
1712
class ProjectResourceGrant(models.Model):
1713

    
1714
    resource                =   models.ForeignKey(Resource)
1715
    project_application     =   models.ForeignKey(ProjectApplication,
1716
                                                  null=True)
1717
    project_capacity        =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1718
    project_import_limit    =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1719
    project_export_limit    =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1720
    member_capacity         =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1721
    member_import_limit     =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1722
    member_export_limit     =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1723

    
1724
    objects = ExtendedManager()
1725

    
1726
    class Meta:
1727
        unique_together = ("resource", "project_application")
1728

    
1729
    def member_quota_values(self):
1730
        return QuotaValues(
1731
            quantity = 0,
1732
            capacity = self.member_capacity,
1733
            import_limit = self.member_import_limit,
1734
            export_limit = self.member_export_limit)
1735

    
1736
    def display_member_capacity(self):
1737
        if self.member_capacity:
1738
            if self.resource.unit:
1739
                return ProjectResourceGrant.display_filesize(
1740
                    self.member_capacity)
1741
            else:
1742
                if math.isinf(self.member_capacity):
1743
                    return 'Unlimited'
1744
                else:
1745
                    return self.member_capacity
1746
        else:
1747
            return 'Unlimited'
1748

    
1749
    def __str__(self):
1750
        return 'Max %s per user: %s' % (self.resource.pluralized_display_name,
1751
                                        self.display_member_capacity())
1752

    
1753
    @classmethod
1754
    def display_filesize(cls, value):
1755
        try:
1756
            value = float(value)
1757
        except:
1758
            return
1759
        else:
1760
            if math.isinf(value):
1761
                return 'Unlimited'
1762
            if value > 1:
1763
                unit_list = zip(['bytes', 'kB', 'MB', 'GB', 'TB', 'PB'],
1764
                                [0, 0, 0, 0, 0, 0])
1765
                exponent = min(int(math.log(value, 1024)), len(unit_list) - 1)
1766
                quotient = float(value) / 1024**exponent
1767
                unit, value_decimals = unit_list[exponent]
1768
                format_string = '{0:.%sf} {1}' % (value_decimals)
1769
                return format_string.format(quotient, unit)
1770
            if value == 0:
1771
                return '0 bytes'
1772
            if value == 1:
1773
                return '1 byte'
1774
            else:
1775
               return '0'
1776

    
1777

    
1778
class ProjectManager(ForUpdateManager):
1779

    
1780
    def terminated_projects(self):
1781
        q = self.model.Q_TERMINATED
1782
        return self.filter(q)
1783

    
1784
    def not_terminated_projects(self):
1785
        q = ~self.model.Q_TERMINATED
1786
        return self.filter(q)
1787

    
1788
    def terminating_projects(self):
1789
        q = self.model.Q_TERMINATED & Q(is_active=True)
1790
        return self.filter(q)
1791

    
1792
    def deactivated_projects(self):
1793
        q = self.model.Q_DEACTIVATED
1794
        return self.filter(q)
1795

    
1796
    def deactivating_projects(self):
1797
        q = self.model.Q_DEACTIVATED & Q(is_active=True)
1798
        return self.filter(q)
1799

    
1800
    def modified_projects(self):
1801
        return self.filter(is_modified=True)
1802

    
1803
    def reactivating_projects(self):
1804
        return self.filter(state=Project.APPROVED, is_active=False)
1805

    
1806
    def expired_projects(self):
1807
        q = (~Q(state=Project.TERMINATED) &
1808
              Q(application__end_date__lt=datetime.now()))
1809
        return self.filter(q)
1810

    
1811
    def search_by_name(self, *search_strings):
1812
        q = Q()
1813
        for s in search_strings:
1814
            q = q | Q(name__icontains=s)
1815
        return self.filter(q)
1816

    
1817

    
1818
class Project(models.Model):
1819

    
1820
    id                          =   models.OneToOneField(Chain,
1821
                                                      related_name='chained_project',
1822
                                                      db_column='id',
1823
                                                      primary_key=True)
1824

    
1825
    application                 =   models.OneToOneField(
1826
                                            ProjectApplication,
1827
                                            related_name='project')
1828
    last_approval_date          =   models.DateTimeField(null=True)
1829

    
1830
    members                     =   models.ManyToManyField(
1831
                                            AstakosUser,
1832
                                            through='ProjectMembership')
1833

    
1834
    deactivation_reason         =   models.CharField(max_length=255, null=True)
1835
    deactivation_date           =   models.DateTimeField(null=True)
1836

    
1837
    creation_date               =   models.DateTimeField(auto_now_add=True)
1838
    name                        =   models.CharField(
1839
                                            max_length=80,
1840
                                            null=True,
1841
                                            db_index=True,
1842
                                            unique=True)
1843

    
1844
    APPROVED    = 1
1845
    SUSPENDED   = 10
1846
    TERMINATED  = 100
1847

    
1848
    is_modified                 =   models.BooleanField(default=False,
1849
                                                        db_index=True)
1850
    is_active                   =   models.BooleanField(default=True,
1851
                                                        db_index=True)
1852
    state                       =   models.IntegerField(default=APPROVED,
1853
                                                        db_index=True)
1854

    
1855
    objects     =   ProjectManager()
1856

    
1857
    # Compiled queries
1858
    Q_TERMINATED  = Q(state=TERMINATED)
1859
    Q_SUSPENDED   = Q(state=SUSPENDED)
1860
    Q_DEACTIVATED = Q_TERMINATED | Q_SUSPENDED
1861

    
1862
    def __str__(self):
1863
        return _("<project %s '%s'>") % (self.id, self.application.name)
1864

    
1865
    __repr__ = __str__
1866

    
1867
    STATE_DISPLAY = {
1868
        APPROVED   : 'Active',
1869
        SUSPENDED  : 'Suspended',
1870
        TERMINATED : 'Terminated'
1871
        }
1872

    
1873
    def state_display(self):
1874
        return self.STATE_DISPLAY.get(self.state, _('Unknown'))
1875

    
1876
    def admin_state_display(self):
1877
        s = self.state_display()
1878
        if self.sync_pending():
1879
            s += ' (sync pending)'
1880
        return s
1881

    
1882
    def sync_pending(self):
1883
        if self.state != self.APPROVED:
1884
            return self.is_active
1885
        return not self.is_active or self.is_modified
1886

    
1887
    def expiration_info(self):
1888
        return (str(self.id), self.name, self.state_display(),
1889
                str(self.application.end_date))
1890

    
1891
    def is_deactivated(self, reason=None):
1892
        if reason is not None:
1893
            return self.state == reason
1894

    
1895
        return self.state != self.APPROVED
1896

    
1897
    def is_deactivating(self, reason=None):
1898
        if not self.is_active:
1899
            return False
1900

    
1901
        return self.is_deactivated(reason)
1902

    
1903
    def is_deactivated_strict(self, reason=None):
1904
        if self.is_active:
1905
            return False
1906

    
1907
        return self.is_deactivated(reason)
1908

    
1909
    ### Deactivation calls
1910

    
1911
    def deactivate(self):
1912
        self.deactivation_date = datetime.now()
1913
        self.is_active = False
1914

    
1915
    def reactivate(self):
1916
        self.deactivation_date = None
1917
        self.is_active = True
1918

    
1919
    def terminate(self):
1920
        self.deactivation_reason = 'TERMINATED'
1921
        self.state = self.TERMINATED
1922
        self.name = None
1923
        self.save()
1924

    
1925
    def suspend(self):
1926
        self.deactivation_reason = 'SUSPENDED'
1927
        self.state = self.SUSPENDED
1928
        self.save()
1929

    
1930
    def resume(self):
1931
        self.deactivation_reason = None
1932
        self.state = self.APPROVED
1933
        self.save()
1934

    
1935
    ### Logical checks
1936

    
1937
    def is_inconsistent(self):
1938
        now = datetime.now()
1939
        dates = [self.creation_date,
1940
                 self.last_approval_date,
1941
                 self.deactivation_date]
1942
        return any([date > now for date in dates])
1943

    
1944
    def is_active_strict(self):
1945
        return self.is_active and self.state == self.APPROVED
1946

    
1947
    def is_approved(self):
1948
        return self.state == self.APPROVED
1949

    
1950
    @property
1951
    def is_alive(self):
1952
        return not self.is_terminated
1953

    
1954
    @property
1955
    def is_terminated(self):
1956
        return self.is_deactivated(self.TERMINATED)
1957

    
1958
    @property
1959
    def is_suspended(self):
1960
        return self.is_deactivated(self.SUSPENDED)
1961

    
1962
    def violates_resource_grants(self):
1963
        return False
1964

    
1965
    def violates_members_limit(self, adding=0):
1966
        application = self.application
1967
        limit = application.limit_on_members_number
1968
        if limit is None:
1969
            return False
1970
        return (len(self.approved_members) + adding > limit)
1971

    
1972

    
1973
    ### Other
1974

    
1975
    def count_pending_memberships(self):
1976
        memb_set = self.projectmembership_set
1977
        memb_count = memb_set.filter(state=ProjectMembership.REQUESTED).count()
1978
        return memb_count
1979

    
1980
    def members_count(self):
1981
        return self.approved_memberships.count()
1982

    
1983
    @property
1984
    def approved_memberships(self):
1985
        query = ProjectMembership.Q_ACCEPTED_STATES
1986
        return self.projectmembership_set.filter(query)
1987

    
1988
    @property
1989
    def approved_members(self):
1990
        return [m.person for m in self.approved_memberships]
1991

    
1992
    def add_member(self, user):
1993
        """
1994
        Raises:
1995
            django.exceptions.PermissionDenied
1996
            astakos.im.models.AstakosUser.DoesNotExist
1997
        """
1998
        if isinstance(user, int):
1999
            user = AstakosUser.objects.get(user=user)
2000

    
2001
        m, created = ProjectMembership.objects.get_or_create(
2002
            person=user, project=self
2003
        )
2004
        m.accept()
2005

    
2006
    def remove_member(self, user):
2007
        """
2008
        Raises:
2009
            django.exceptions.PermissionDenied
2010
            astakos.im.models.AstakosUser.DoesNotExist
2011
            astakos.im.models.ProjectMembership.DoesNotExist
2012
        """
2013
        if isinstance(user, int):
2014
            user = AstakosUser.objects.get(user=user)
2015

    
2016
        m = ProjectMembership.objects.get(person=user, project=self)
2017
        m.remove()
2018

    
2019

    
2020
CHAIN_STATE = {
2021
    (Project.APPROVED,   ProjectApplication.PENDING)  : Chain.APPROVED_PENDING,
2022
    (Project.APPROVED,   ProjectApplication.APPROVED) : Chain.APPROVED,
2023
    (Project.APPROVED,   ProjectApplication.DENIED)   : Chain.APPROVED,
2024
    (Project.APPROVED,   ProjectApplication.DISMISSED): Chain.APPROVED,
2025
    (Project.APPROVED,   ProjectApplication.CANCELLED): Chain.APPROVED,
2026

    
2027
    (Project.SUSPENDED,  ProjectApplication.PENDING)  : Chain.SUSPENDED_PENDING,
2028
    (Project.SUSPENDED,  ProjectApplication.APPROVED) : Chain.SUSPENDED,
2029
    (Project.SUSPENDED,  ProjectApplication.DENIED)   : Chain.SUSPENDED,
2030
    (Project.SUSPENDED,  ProjectApplication.DISMISSED): Chain.SUSPENDED,
2031
    (Project.SUSPENDED,  ProjectApplication.CANCELLED): Chain.SUSPENDED,
2032

    
2033
    (Project.TERMINATED, ProjectApplication.PENDING)  : Chain.TERMINATED_PENDING,
2034
    (Project.TERMINATED, ProjectApplication.APPROVED) : Chain.TERMINATED,
2035
    (Project.TERMINATED, ProjectApplication.DENIED)   : Chain.TERMINATED,
2036
    (Project.TERMINATED, ProjectApplication.DISMISSED): Chain.TERMINATED,
2037
    (Project.TERMINATED, ProjectApplication.CANCELLED): Chain.TERMINATED,
2038

    
2039
    (None,               ProjectApplication.PENDING)  : Chain.PENDING,
2040
    (None,               ProjectApplication.DENIED)   : Chain.DENIED,
2041
    (None,               ProjectApplication.DISMISSED): Chain.DISMISSED,
2042
    (None,               ProjectApplication.CANCELLED): Chain.CANCELLED,
2043
    }
2044

    
2045

    
2046
class PendingMembershipError(Exception):
2047
    pass
2048

    
2049

    
2050
class ProjectMembershipManager(ForUpdateManager):
2051

    
2052
    def any_accepted(self):
2053
        q = (Q(state=ProjectMembership.ACCEPTED) |
2054
             Q(state=ProjectMembership.PROJECT_DEACTIVATED))
2055
        return self.filter(q)
2056

    
2057
    def requested(self):
2058
        return self.filter(state=ProjectMembership.REQUESTED)
2059

    
2060
    def suspended(self):
2061
        return self.filter(state=ProjectMembership.USER_SUSPENDED)
2062

    
2063
class ProjectMembership(models.Model):
2064

    
2065
    person              =   models.ForeignKey(AstakosUser)
2066
    request_date        =   models.DateField(auto_now_add=True)
2067
    project             =   models.ForeignKey(Project)
2068

    
2069
    REQUESTED           =   0
2070
    ACCEPTED            =   1
2071
    # User deactivation
2072
    USER_SUSPENDED      =   10
2073
    # Project deactivation
2074
    PROJECT_DEACTIVATED =   100
2075

    
2076
    REMOVED             =   200
2077

    
2078
    ASSOCIATED_STATES   =   set([REQUESTED,
2079
                                 ACCEPTED,
2080
                                 USER_SUSPENDED,
2081
                                 PROJECT_DEACTIVATED])
2082

    
2083
    ACCEPTED_STATES     =   set([ACCEPTED,
2084
                                 USER_SUSPENDED,
2085
                                 PROJECT_DEACTIVATED])
2086

    
2087
    state               =   models.IntegerField(default=REQUESTED,
2088
                                                db_index=True)
2089
    is_pending          =   models.BooleanField(default=False, db_index=True)
2090
    is_active           =   models.BooleanField(default=False, db_index=True)
2091
    application         =   models.ForeignKey(
2092
                                ProjectApplication,
2093
                                null=True,
2094
                                related_name='memberships')
2095
    pending_application =   models.ForeignKey(
2096
                                ProjectApplication,
2097
                                null=True,
2098
                                related_name='pending_memberships')
2099
    pending_serial      =   models.BigIntegerField(null=True, db_index=True)
2100

    
2101
    acceptance_date     =   models.DateField(null=True, db_index=True)
2102
    leave_request_date  =   models.DateField(null=True)
2103

    
2104
    objects     =   ProjectMembershipManager()
2105

    
2106
    # Compiled queries
2107
    Q_ACCEPTED_STATES = ~Q(state=REQUESTED) & ~Q(state=REMOVED)
2108

    
2109
    MEMBERSHIP_STATE_DISPLAY = {
2110
        REQUESTED           : _('Requested'),
2111
        ACCEPTED            : _('Accepted'),
2112
        USER_SUSPENDED      : _('Suspended'),
2113
        PROJECT_DEACTIVATED : _('Accepted'), # sic
2114
        REMOVED             : _('Pending removal'),
2115
        }
2116

    
2117
    USER_FRIENDLY_STATE_DISPLAY = {
2118
        REQUESTED           : _('Join requested'),
2119
        ACCEPTED            : _('Accepted member'),
2120
        USER_SUSPENDED      : _('Suspended member'),
2121
        PROJECT_DEACTIVATED : _('Accepted member'), # sic
2122
        REMOVED             : _('Pending removal'),
2123
        }
2124

    
2125
    def state_display(self):
2126
        return self.MEMBERSHIP_STATE_DISPLAY.get(self.state, _('Unknown'))
2127

    
2128
    def user_friendly_state_display(self):
2129
        return self.USER_FRIENDLY_STATE_DISPLAY.get(self.state, _('Unknown'))
2130

    
2131
    def get_combined_state(self):
2132
        return self.state, self.is_active, self.is_pending
2133

    
2134
    class Meta:
2135
        unique_together = ("person", "project")
2136
        #index_together = [["project", "state"]]
2137

    
2138
    def __str__(self):
2139
        return _("<'%s' membership in '%s'>") % (
2140
                self.person.username, self.project)
2141

    
2142
    __repr__ = __str__
2143

    
2144
    def __init__(self, *args, **kwargs):
2145
        self.state = self.REQUESTED
2146
        super(ProjectMembership, self).__init__(*args, **kwargs)
2147

    
2148
    def _set_history_item(self, reason, date=None):
2149
        if isinstance(reason, basestring):
2150
            reason = ProjectMembershipHistory.reasons.get(reason, -1)
2151

    
2152
        history_item = ProjectMembershipHistory(
2153
                            serial=self.id,
2154
                            person=self.person_id,
2155
                            project=self.project_id,
2156
                            date=date or datetime.now(),
2157
                            reason=reason)
2158
        history_item.save()
2159
        serial = history_item.id
2160

    
2161
    def can_accept(self):
2162
        return self.state == self.REQUESTED
2163

    
2164
    def accept(self):
2165
        if self.is_pending:
2166
            m = _("%s: attempt to accept while is pending") % (self,)
2167
            raise AssertionError(m)
2168

    
2169
        if not self.can_accept():
2170
            m = _("%s: attempt to accept in state '%s'") % (self, self.state)
2171
            raise AssertionError(m)
2172

    
2173
        now = datetime.now()
2174
        self.acceptance_date = now
2175
        self._set_history_item(reason='ACCEPT', date=now)
2176
        if self.project.is_approved():
2177
            self.state = self.ACCEPTED
2178
            self.is_pending = True
2179
        else:
2180
            self.state = self.PROJECT_DEACTIVATED
2181

    
2182
        self.save()
2183

    
2184
    def can_leave(self):
2185
        return self.can_remove()
2186

    
2187
    def can_remove(self):
2188
        return self.state in self.ACCEPTED_STATES
2189

    
2190
    def remove(self):
2191
        if self.is_pending:
2192
            m = _("%s: attempt to remove while is pending") % (self,)
2193
            raise AssertionError(m)
2194

    
2195
        if not self.can_remove():
2196
            m = _("%s: attempt to remove in state '%s'") % (self, self.state)
2197
            raise AssertionError(m)
2198

    
2199
        self._set_history_item(reason='REMOVE')
2200
        self.state = self.REMOVED
2201
        self.is_pending = True
2202
        self.save()
2203

    
2204
    def can_reject(self):
2205
        return self.state == self.REQUESTED
2206

    
2207
    def reject(self):
2208
        if self.is_pending:
2209
            m = _("%s: attempt to reject while is pending") % (self,)
2210
            raise AssertionError(m)
2211

    
2212
        if not self.can_reject():
2213
            m = _("%s: attempt to reject in state '%s'") % (self, self.state)
2214
            raise AssertionError(m)
2215

    
2216
        # rejected requests don't need sync,
2217
        # because they were never effected
2218
        self._set_history_item(reason='REJECT')
2219
        self.delete()
2220

    
2221
    def can_cancel(self):
2222
        return self.state == self.REQUESTED
2223

    
2224
    def cancel(self):
2225
        if self.is_pending:
2226
            m = _("%s: attempt to cancel while is pending") % (self,)
2227
            raise AssertionError(m)
2228

    
2229
        if not self.can_cancel():
2230
            m = _("%s: attempt to cancel in state '%s'") % (self, self.state)
2231
            raise AssertionError(m)
2232

    
2233
        # rejected requests don't need sync,
2234
        # because they were never effected
2235
        self._set_history_item(reason='CANCEL')
2236
        self.delete()
2237

    
2238
    def get_diff_quotas(self, sub_list=None, add_list=None):
2239
        if sub_list is None:
2240
            sub_list = []
2241

    
2242
        if add_list is None:
2243
            add_list = []
2244

    
2245
        sub_append = sub_list.append
2246
        add_append = add_list.append
2247
        holder = self.person.uuid
2248

    
2249
        synced_application = self.application
2250
        if synced_application is not None:
2251
            cur_grants = synced_application.projectresourcegrant_set.all()
2252
            for grant in cur_grants:
2253
                sub_append(QuotaLimits(
2254
                               holder       = holder,
2255
                               resource     = str(grant.resource),
2256
                               capacity     = grant.member_capacity,
2257
                               import_limit = grant.member_import_limit,
2258
                               export_limit = grant.member_export_limit))
2259

    
2260
        pending_application = self.pending_application
2261
        if pending_application is not None:
2262
            new_grants = pending_application.projectresourcegrant_set.all()
2263
            for new_grant in new_grants:
2264
                add_append(QuotaLimits(
2265
                               holder       = holder,
2266
                               resource     = str(new_grant.resource),
2267
                               capacity     = new_grant.member_capacity,
2268
                               import_limit = new_grant.member_import_limit,
2269
                               export_limit = new_grant.member_export_limit))
2270

    
2271
        return (sub_list, add_list)
2272

    
2273
    def set_sync(self):
2274
        if not self.is_pending:
2275
            m = _("%s: attempt to sync a non pending membership") % (self,)
2276
            raise AssertionError(m)
2277

    
2278
        state = self.state
2279
        if state == self.ACCEPTED:
2280
            pending_application = self.pending_application
2281
            if pending_application is None:
2282
                m = _("%s: attempt to sync an empty pending application") % (
2283
                    self,)
2284
                raise AssertionError(m)
2285

    
2286
            self.application = pending_application
2287
            self.is_active = True
2288

    
2289
            self.pending_application = None
2290
            self.pending_serial = None
2291

    
2292
            # project.application may have changed in the meantime,
2293
            # in which case we stay PENDING;
2294
            # we are safe to check due to select_for_update
2295
            if self.application == self.project.application:
2296
                self.is_pending = False
2297
            self.save()
2298

    
2299
        elif state == self.PROJECT_DEACTIVATED:
2300
            if self.pending_application:
2301
                m = _("%s: attempt to sync in state '%s' "
2302
                      "with a pending application") % (self, state)
2303
                raise AssertionError(m)
2304

    
2305
            self.application = None
2306
            self.is_active = False
2307
            self.pending_serial = None
2308
            self.is_pending = False
2309
            self.save()
2310

    
2311
        elif state == self.REMOVED:
2312
            self.delete()
2313

    
2314
        else:
2315
            m = _("%s: attempt to sync in state '%s'") % (self, state)
2316
            raise AssertionError(m)
2317

    
2318
    def reset_sync(self):
2319
        if not self.is_pending:
2320
            m = _("%s: attempt to reset a non pending membership") % (self,)
2321
            raise AssertionError(m)
2322

    
2323
        state = self.state
2324
        if state in [self.ACCEPTED, self.PROJECT_DEACTIVATED, self.REMOVED]:
2325
            self.pending_application = None
2326
            self.pending_serial = None
2327
            self.save()
2328
        else:
2329
            m = _("%s: attempt to reset sync in state '%s'") % (self, state)
2330
            raise AssertionError(m)
2331

    
2332
class Serial(models.Model):
2333
    serial  =   models.AutoField(primary_key=True)
2334

    
2335
def new_serial():
2336
    s = Serial.objects.create()
2337
    serial = s.serial
2338
    s.delete()
2339
    return serial
2340

    
2341
def sync_finish_serials(serials_to_ack=None):
2342
    if serials_to_ack is None:
2343
        serials_to_ack = qh_query_serials([])
2344

    
2345
    serials_to_ack = set(serials_to_ack)
2346
    sfu = ProjectMembership.objects.select_for_update()
2347
    memberships = list(sfu.filter(pending_serial__isnull=False))
2348

    
2349
    if memberships:
2350
        for membership in memberships:
2351
            serial = membership.pending_serial
2352
            if serial in serials_to_ack:
2353
                membership.set_sync()
2354
            else:
2355
                membership.reset_sync()
2356

    
2357
        transaction.commit()
2358

    
2359
    qh_ack_serials(list(serials_to_ack))
2360
    return len(memberships)
2361

    
2362
def pre_sync_projects(sync=True):
2363
    ACCEPTED = ProjectMembership.ACCEPTED
2364
    PROJECT_DEACTIVATED = ProjectMembership.PROJECT_DEACTIVATED
2365
    psfu = Project.objects.select_for_update()
2366

    
2367
    modified = list(psfu.modified_projects())
2368
    if sync:
2369
        for project in modified:
2370
            objects = project.projectmembership_set.select_for_update()
2371

    
2372
            memberships = objects.filter(state=ACCEPTED)
2373
            for membership in memberships:
2374
                membership.is_pending = True
2375
                membership.save()
2376

    
2377
    reactivating = list(psfu.reactivating_projects())
2378
    if sync:
2379
        for project in reactivating:
2380
            objects = project.projectmembership_set.select_for_update()
2381

    
2382
            memberships = objects.filter(state=PROJECT_DEACTIVATED)
2383
            for membership in memberships:
2384
                membership.is_pending = True
2385
                membership.state = ACCEPTED
2386
                membership.save()
2387

    
2388
    deactivating = list(psfu.deactivating_projects())
2389
    if sync:
2390
        for project in deactivating:
2391
            objects = project.projectmembership_set.select_for_update()
2392

    
2393
            # Note: we keep a user-level deactivation
2394
            # (e.g. USER_SUSPENDED) intact
2395
            memberships = objects.filter(state=ACCEPTED)
2396
            for membership in memberships:
2397
                membership.is_pending = True
2398
                membership.state = PROJECT_DEACTIVATED
2399
                membership.save()
2400

    
2401
    return (modified, reactivating, deactivating)
2402

    
2403
def do_sync_projects():
2404

    
2405
    ACCEPTED = ProjectMembership.ACCEPTED
2406
    objects = ProjectMembership.objects.select_for_update()
2407

    
2408
    sub_quota, add_quota = [], []
2409

    
2410
    serial = new_serial()
2411

    
2412
    pending = objects.filter(is_pending=True)
2413
    for membership in pending:
2414

    
2415
        if membership.pending_application:
2416
            m = "%s: impossible: pending_application is not None (%s)" % (
2417
                membership, membership.pending_application)
2418
            raise AssertionError(m)
2419
        if membership.pending_serial:
2420
            m = "%s: impossible: pending_serial is not None (%s)" % (
2421
                membership, membership.pending_serial)
2422
            raise AssertionError(m)
2423

    
2424
        if membership.state == ACCEPTED:
2425
            membership.pending_application = membership.project.application
2426

    
2427
        membership.pending_serial = serial
2428
        membership.get_diff_quotas(sub_quota, add_quota)
2429
        membership.save()
2430

    
2431
    transaction.commit()
2432
    # ProjectApplication.approve() unblocks here
2433
    # and can set PENDING an already PENDING membership
2434
    # which has been scheduled to sync with the old project.application
2435
    # Need to check in ProjectMembership.set_sync()
2436

    
2437
    r = qh_add_quota(serial, sub_quota, add_quota)
2438
    if r:
2439
        m = "cannot sync serial: %d" % serial
2440
        raise RuntimeError(m)
2441

    
2442
    return serial
2443

    
2444
def post_sync_projects():
2445
    ACCEPTED = ProjectMembership.ACCEPTED
2446
    PROJECT_DEACTIVATED = ProjectMembership.PROJECT_DEACTIVATED
2447
    psfu = Project.objects.select_for_update()
2448

    
2449
    modified = psfu.modified_projects()
2450
    for project in modified:
2451
        objects = project.projectmembership_set.select_for_update()
2452

    
2453
        memberships = list(objects.filter(state=ACCEPTED, is_pending=True))
2454
        if not memberships:
2455
            project.is_modified = False
2456
            project.save()
2457

    
2458
    reactivating = psfu.reactivating_projects()
2459
    for project in reactivating:
2460
        objects = project.projectmembership_set.select_for_update()
2461
        memberships = list(objects.filter(Q(state=PROJECT_DEACTIVATED) |
2462
                                          Q(is_pending=True)))
2463
        if not memberships:
2464
            project.reactivate()
2465
            project.save()
2466

    
2467
    deactivating = psfu.deactivating_projects()
2468
    for project in deactivating:
2469
        objects = project.projectmembership_set.select_for_update()
2470

    
2471
        memberships = list(objects.filter(Q(state=ACCEPTED) |
2472
                                          Q(is_pending=True)))
2473
        if not memberships:
2474
            project.deactivate()
2475
            project.save()
2476

    
2477
    transaction.commit()
2478

    
2479
def sync_projects(sync=True, retries=3, retry_wait=1.0):
2480
    @with_lock(retries, retry_wait)
2481
    def _sync_projects(sync):
2482
        sync_finish_serials()
2483
        # Informative only -- no select_for_update()
2484
        pending = list(ProjectMembership.objects.filter(is_pending=True))
2485

    
2486
        projects_log = pre_sync_projects(sync)
2487
        if sync:
2488
            serial = do_sync_projects()
2489
            sync_finish_serials([serial])
2490
            post_sync_projects()
2491

    
2492
        return (pending, projects_log)
2493
    return _sync_projects(sync)
2494

    
2495
def all_users_quotas(users):
2496
    quotas = {}
2497
    for user in users:
2498
        quotas[user.uuid] = user.all_quotas()
2499
    return quotas
2500

    
2501
def sync_users(users, sync=True, retries=3, retry_wait=1.0):
2502
    @with_lock(retries, retry_wait)
2503
    def _sync_users(users, sync):
2504
        sync_finish_serials()
2505

    
2506
        existing, nonexisting = qh_check_users(users)
2507
        resources = get_resource_names()
2508
        registered_quotas = qh_get_quota_limits(existing, resources)
2509
        astakos_quotas = all_users_quotas(users)
2510

    
2511
        if sync:
2512
            r = register_users(nonexisting)
2513
            r = send_quotas(astakos_quotas)
2514

    
2515
        return (existing, nonexisting, registered_quotas, astakos_quotas)
2516
    return _sync_users(users, sync)
2517

    
2518
def sync_all_users(sync=True, retries=3, retry_wait=1.0):
2519
    users = AstakosUser.objects.filter(is_active=True)
2520
    return sync_users(users, sync, retries, retry_wait)
2521

    
2522
class ProjectMembershipHistory(models.Model):
2523
    reasons_list    =   ['ACCEPT', 'REJECT', 'REMOVE']
2524
    reasons         =   dict((k, v) for v, k in enumerate(reasons_list))
2525

    
2526
    person  =   models.BigIntegerField()
2527
    project =   models.BigIntegerField()
2528
    date    =   models.DateField(auto_now_add=True)
2529
    reason  =   models.IntegerField()
2530
    serial  =   models.BigIntegerField()
2531

    
2532
### SIGNALS ###
2533
################
2534

    
2535
def create_astakos_user(u):
2536
    try:
2537
        AstakosUser.objects.get(user_ptr=u.pk)
2538
    except AstakosUser.DoesNotExist:
2539
        extended_user = AstakosUser(user_ptr_id=u.pk)
2540
        extended_user.__dict__.update(u.__dict__)
2541
        extended_user.save()
2542
        if not extended_user.has_auth_provider('local'):
2543
            extended_user.add_auth_provider('local')
2544
    except BaseException, e:
2545
        logger.exception(e)
2546

    
2547
def fix_superusers():
2548
    # Associate superusers with AstakosUser
2549
    admins = User.objects.filter(is_superuser=True)
2550
    for u in admins:
2551
        create_astakos_user(u)
2552

    
2553
def user_post_save(sender, instance, created, **kwargs):
2554
    if not created:
2555
        return
2556
    create_astakos_user(instance)
2557
post_save.connect(user_post_save, sender=User)
2558

    
2559
def astakosuser_post_save(sender, instance, created, **kwargs):
2560
    pass
2561

    
2562
post_save.connect(astakosuser_post_save, sender=AstakosUser)
2563

    
2564
def resource_post_save(sender, instance, created, **kwargs):
2565
    pass
2566

    
2567
post_save.connect(resource_post_save, sender=Resource)
2568

    
2569
def renew_token(sender, instance, **kwargs):
2570
    if not instance.auth_token:
2571
        instance.renew_token()
2572
pre_save.connect(renew_token, sender=AstakosUser)
2573
pre_save.connect(renew_token, sender=Service)