Statistics
| Branch: | Tag: | Revision:

root / snf-astakos-app / astakos / im / models.py @ d833fcbb

History | View | Annotate | Download (90 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, PROJECT_ADMINS)
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 uuid_catalog(self, l=None):
316
        """
317
        Returns a uuid to username mapping for the uuids appearing in l.
318
        If l is None returns the mapping for all existing users.
319
        """
320
        q = self.filter(uuid__in=l) if l != None else self
321
        return dict(q.values_list('uuid', 'username'))
322

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

    
337

    
338

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

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

    
355

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

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

    
368
    updated = models.DateTimeField(_('Update date'))
369
    is_verified = models.BooleanField(_('Is verified?'), default=False)
370

    
371
    email_verified = models.BooleanField(_('Email verified?'), default=False)
372

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

    
379
    activation_sent = models.DateTimeField(
380
        _('Activation sent data'), null=True, blank=True)
381

    
382
    policy = models.ManyToManyField(
383
        Resource, null=True, through='AstakosUserQuota')
384

    
385
    uuid = models.CharField(max_length=255, null=True, blank=False, unique=True)
386

    
387
    __has_signed_terms = False
388
    disturbed_quota = models.BooleanField(_('Needs quotaholder syncing'),
389
                                           default=False, db_index=True)
390

    
391
    objects = AstakosUserManager()
392

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

    
399
    @property
400
    def realname(self):
401
        return '%s %s' % (self.first_name, self.last_name)
402

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

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

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

    
428
    def is_project_admin(self, application_id=None):
429
        return self.uuid in PROJECT_ADMINS
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_primary_auth_provider(self):
725
        return self.get_auth_providers().filter()[0]
726

    
727
    def get_auth_providers(self):
728
        return self.auth_providers
729

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

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

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

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

    
761
    @property
762
    def auth_providers_display(self):
763
        return ",".join(map(lambda x:unicode(x), self.get_auth_providers().active()))
764

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

    
790
        return mark_safe(message + u' '+ msg_extra)
791

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

    
795
    def owns_project(self, project):
796
        return project.application.owner == self
797

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

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

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

    
820
    def non_owner_can_view(self, maybe_project):
821
        if self.is_project_admin():
822
            return True
823
        if maybe_project is None:
824
            return False
825
        project = maybe_project
826
        if self.is_associated(project):
827
            return True
828
        if project.is_deactivated():
829
            return False
830
        return True
831

    
832

    
833
class AstakosUserAuthProviderManager(models.Manager):
834

    
835
    def active(self, **filters):
836
        return self.filter(active=True, **filters)
837

    
838
    def remove_unverified_providers(self, provider, **filters):
839
        try:
840
            existing = self.filter(module=provider, user__email_verified=False, **filters)
841
            for p in existing:
842
                p.user.delete()
843
        except:
844
            pass
845

    
846

    
847

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

    
866
    objects = AstakosUserAuthProviderManager()
867

    
868
    class Meta:
869
        unique_together = (('identifier', 'module', 'user'), )
870
        ordering = ('module', 'created')
871

    
872
    def __init__(self, *args, **kwargs):
873
        super(AstakosUserAuthProvider, self).__init__(*args, **kwargs)
874
        try:
875
            self.info = json.loads(self.info_data)
876
            if not self.info:
877
                self.info = {}
878
        except Exception, e:
879
            self.info = {}
880

    
881
        for key,value in self.info.iteritems():
882
            setattr(self, 'info_%s' % key, value)
883

    
884

    
885
    @property
886
    def settings(self):
887
        return auth_providers.get_provider(self.module)
888

    
889
    @property
890
    def details_display(self):
891
        try:
892
            params = self.user.__dict__
893
            params.update(self.__dict__)
894
            return self.settings.get_details_tpl_display % params
895
        except:
896
            return ''
897

    
898
    @property
899
    def title_display(self):
900
        title_tpl = self.settings.get_title_display
901
        try:
902
            if self.settings.get_user_title_display:
903
                title_tpl = self.settings.get_user_title_display
904
        except Exception, e:
905
            pass
906
        try:
907
          return title_tpl % self.__dict__
908
        except:
909
          return self.settings.get_title_display % self.__dict__
910

    
911
    def can_remove(self):
912
        return self.user.can_remove_auth_provider(self.module)
913

    
914
    def delete(self, *args, **kwargs):
915
        ret = super(AstakosUserAuthProvider, self).delete(*args, **kwargs)
916
        if self.module == 'local':
917
            self.user.set_unusable_password()
918
            self.user.save()
919
        return ret
920

    
921
    def __repr__(self):
922
        return '<AstakosUserAuthProvider %s:%s>' % (self.module, self.identifier)
923

    
924
    def __unicode__(self):
925
        if self.identifier:
926
            return "%s:%s" % (self.module, self.identifier)
927
        if self.auth_backend:
928
            return "%s:%s" % (self.module, self.auth_backend)
929
        return self.module
930

    
931
    def save(self, *args, **kwargs):
932
        self.info_data = json.dumps(self.info)
933
        return super(AstakosUserAuthProvider, self).save(*args, **kwargs)
934

    
935

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

    
963
    update_or_create = _update_or_create
964

    
965

    
966
class AstakosUserQuota(models.Model):
967
    objects = ExtendedManager()
968
    capacity = intDecimalField()
969
    quantity = intDecimalField(default=0)
970
    export_limit = intDecimalField(default=QH_PRACTICALLY_INFINITE)
971
    import_limit = intDecimalField(default=QH_PRACTICALLY_INFINITE)
972
    resource = models.ForeignKey(Resource)
973
    user = models.ForeignKey(AstakosUser)
974

    
975
    class Meta:
976
        unique_together = ("resource", "user")
977

    
978
    def quota_values(self):
979
        return QuotaValues(
980
            quantity = self.quantity,
981
            capacity = self.capacity,
982
            import_limit = self.import_limit,
983
            export_limit = self.export_limit)
984

    
985

    
986
class ApprovalTerms(models.Model):
987
    """
988
    Model for approval terms
989
    """
990

    
991
    date = models.DateTimeField(
992
        _('Issue date'), db_index=True, auto_now_add=True)
993
    location = models.CharField(_('Terms location'), max_length=255)
994

    
995

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

    
1009
    def __init__(self, *args, **kwargs):
1010
        super(Invitation, self).__init__(*args, **kwargs)
1011
        if not self.id:
1012
            self.code = _generate_invitation_code()
1013

    
1014
    def consume(self):
1015
        self.is_consumed = True
1016
        self.consumed = datetime.now()
1017
        self.save()
1018

    
1019
    def __unicode__(self):
1020
        return '%s -> %s [%d]' % (self.inviter, self.username, self.code)
1021

    
1022

    
1023
class EmailChangeManager(models.Manager):
1024

    
1025
    @transaction.commit_on_success
1026
    def change_email(self, activation_key):
1027
        """
1028
        Validate an activation key and change the corresponding
1029
        ``User`` if valid.
1030

1031
        If the key is valid and has not expired, return the ``User``
1032
        after activating.
1033

1034
        If the key is not valid or has expired, return ``None``.
1035

1036
        If the key is valid but the ``User`` is already active,
1037
        return ``None``.
1038

1039
        After successful email change the activation record is deleted.
1040

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

    
1069

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

    
1082
    objects = EmailChangeManager()
1083

    
1084
    def get_url(self):
1085
        return reverse('email_change_confirm',
1086
                      kwargs={'activation_key': self.activation_key})
1087

    
1088
    def activation_key_expired(self):
1089
        expiration_date = timedelta(days=EMAILCHANGE_ACTIVATION_DAYS)
1090
        return self.requested_at + expiration_date < datetime.now()
1091

    
1092

    
1093
class AdditionalMail(models.Model):
1094
    """
1095
    Model for registring invitations
1096
    """
1097
    owner = models.ForeignKey(AstakosUser)
1098
    email = models.EmailField()
1099

    
1100

    
1101
def _generate_invitation_code():
1102
    while True:
1103
        code = randint(1, 2L ** 63 - 1)
1104
        try:
1105
            Invitation.objects.get(code=code)
1106
            # An invitation with this code already exists, try again
1107
        except Invitation.DoesNotExist:
1108
            return code
1109

    
1110

    
1111
def get_latest_terms():
1112
    try:
1113
        term = ApprovalTerms.objects.order_by('-id')[0]
1114
        return term
1115
    except IndexError:
1116
        pass
1117
    return None
1118

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

    
1137
    class Meta:
1138
        unique_together = ("provider", "third_party_identifier")
1139

    
1140
    def get_user_instance(self):
1141
        d = self.__dict__
1142
        d.pop('_state', None)
1143
        d.pop('id', None)
1144
        d.pop('token', None)
1145
        d.pop('created', None)
1146
        d.pop('info', None)
1147
        user = AstakosUser(**d)
1148

    
1149
        return user
1150

    
1151
    @property
1152
    def realname(self):
1153
        return '%s %s' %(self.first_name, self.last_name)
1154

    
1155
    @realname.setter
1156
    def realname(self, value):
1157
        parts = value.split(' ')
1158
        if len(parts) == 2:
1159
            self.first_name = parts[0]
1160
            self.last_name = parts[1]
1161
        else:
1162
            self.last_name = parts[0]
1163

    
1164
    def save(self, **kwargs):
1165
        if not self.id:
1166
            # set username
1167
            while not self.username:
1168
                username =  uuid.uuid4().hex[:30]
1169
                try:
1170
                    AstakosUser.objects.get(username = username)
1171
                except AstakosUser.DoesNotExist, e:
1172
                    self.username = username
1173
        super(PendingThirdPartyUser, self).save(**kwargs)
1174

    
1175
    def generate_token(self):
1176
        self.password = self.third_party_identifier
1177
        self.last_login = datetime.now()
1178
        self.token = default_token_generator.make_token(self)
1179

    
1180
    def existing_user(self):
1181
        return AstakosUser.objects.filter(auth_providers__module=self.provider,
1182
                                         auth_providers__identifier=self.third_party_identifier)
1183

    
1184
class SessionCatalog(models.Model):
1185
    session_key = models.CharField(_('session key'), max_length=40)
1186
    user = models.ForeignKey(AstakosUser, related_name='sessions', null=True)
1187

    
1188

    
1189
### PROJECTS ###
1190
################
1191

    
1192
def synced_model_metaclass(class_name, class_parents, class_attributes):
1193

    
1194
    new_attributes = {}
1195
    sync_attributes = {}
1196

    
1197
    for name, value in class_attributes.iteritems():
1198
        sync, underscore, rest = name.partition('_')
1199
        if sync == 'sync' and underscore == '_':
1200
            sync_attributes[rest] = value
1201
        else:
1202
            new_attributes[name] = value
1203

    
1204
    if 'prefix' not in sync_attributes:
1205
        m = ("you did not specify a 'sync_prefix' attribute "
1206
             "in class '%s'" % (class_name,))
1207
        raise ValueError(m)
1208

    
1209
    prefix = sync_attributes.pop('prefix')
1210
    class_name = sync_attributes.pop('classname', prefix + '_model')
1211

    
1212
    for name, value in sync_attributes.iteritems():
1213
        newname = prefix + '_' + name
1214
        if newname in new_attributes:
1215
            m = ("class '%s' was specified with prefix '%s' "
1216
                 "but it already has an attribute named '%s'"
1217
                 % (class_name, prefix, newname))
1218
            raise ValueError(m)
1219

    
1220
        new_attributes[newname] = value
1221

    
1222
    newclass = type(class_name, class_parents, new_attributes)
1223
    return newclass
1224

    
1225

    
1226
def make_synced(prefix='sync', name='SyncedState'):
1227

    
1228
    the_name = name
1229
    the_prefix = prefix
1230

    
1231
    class SyncedState(models.Model):
1232

    
1233
        sync_classname      = the_name
1234
        sync_prefix         = the_prefix
1235
        __metaclass__       = synced_model_metaclass
1236

    
1237
        sync_new_state      = models.BigIntegerField(null=True)
1238
        sync_synced_state   = models.BigIntegerField(null=True)
1239
        STATUS_SYNCED       = 0
1240
        STATUS_PENDING      = 1
1241
        sync_status         = models.IntegerField(db_index=True)
1242

    
1243
        class Meta:
1244
            abstract = True
1245

    
1246
        class NotSynced(Exception):
1247
            pass
1248

    
1249
        def sync_init_state(self, state):
1250
            self.sync_synced_state = state
1251
            self.sync_new_state = state
1252
            self.sync_status = self.STATUS_SYNCED
1253

    
1254
        def sync_get_status(self):
1255
            return self.sync_status
1256

    
1257
        def sync_set_status(self):
1258
            if self.sync_new_state != self.sync_synced_state:
1259
                self.sync_status = self.STATUS_PENDING
1260
            else:
1261
                self.sync_status = self.STATUS_SYNCED
1262

    
1263
        def sync_set_synced(self):
1264
            self.sync_synced_state = self.sync_new_state
1265
            self.sync_status = self.STATUS_SYNCED
1266

    
1267
        def sync_get_synced_state(self):
1268
            return self.sync_synced_state
1269

    
1270
        def sync_set_new_state(self, new_state):
1271
            self.sync_new_state = new_state
1272
            self.sync_set_status()
1273

    
1274
        def sync_get_new_state(self):
1275
            return self.sync_new_state
1276

    
1277
        def sync_set_synced_state(self, synced_state):
1278
            self.sync_synced_state = synced_state
1279
            self.sync_set_status()
1280

    
1281
        def sync_get_pending_objects(self):
1282
            kw = dict((the_prefix + '_status', self.STATUS_PENDING))
1283
            return self.objects.filter(**kw)
1284

    
1285
        def sync_get_synced_objects(self):
1286
            kw = dict((the_prefix + '_status', self.STATUS_SYNCED))
1287
            return self.objects.filter(**kw)
1288

    
1289
        def sync_verify_get_synced_state(self):
1290
            status = self.sync_get_status()
1291
            state = self.sync_get_synced_state()
1292
            verified = (status == self.STATUS_SYNCED)
1293
            return state, verified
1294

    
1295
        def sync_is_synced(self):
1296
            state, verified = self.sync_verify_get_synced_state()
1297
            return verified
1298

    
1299
    return SyncedState
1300

    
1301
SyncedState = make_synced(prefix='sync', name='SyncedState')
1302

    
1303

    
1304
class ChainManager(ForUpdateManager):
1305

    
1306
    def search_by_name(self, *search_strings):
1307
        projects = Project.objects.search_by_name(*search_strings)
1308
        chains = [p.id for p in projects]
1309
        apps  = ProjectApplication.objects.search_by_name(*search_strings)
1310
        apps = (app for app in apps if app.is_latest())
1311
        app_chains = [app.chain for app in apps if app.chain not in chains]
1312
        return chains + app_chains
1313

    
1314
    def all_full_state(self):
1315
        d = {}
1316
        chains = self.all()
1317
        for chain in chains:
1318
            d[chain.pk] = chain.full_state()
1319
        return d
1320

    
1321
    def of_project(self, project):
1322
        if project is None:
1323
            return None
1324
        try:
1325
            return self.get(chain=project.id)
1326
        except Chain.DoesNotExist:
1327
            raise AssertionError('project with no chain')
1328

    
1329

    
1330
class Chain(models.Model):
1331
    chain  =   models.AutoField(primary_key=True)
1332

    
1333
    def __str__(self):
1334
        return "%s" % (self.chain,)
1335

    
1336
    objects = ChainManager()
1337

    
1338
    PENDING            = 0
1339
    DENIED             = 3
1340
    DISMISSED          = 4
1341
    CANCELLED          = 5
1342

    
1343
    APPROVED           = 10
1344
    APPROVED_PENDING   = 11
1345
    SUSPENDED          = 12
1346
    SUSPENDED_PENDING  = 13
1347
    TERMINATED         = 14
1348
    TERMINATED_PENDING = 15
1349

    
1350
    PENDING_STATES = [PENDING,
1351
                      APPROVED_PENDING,
1352
                      SUSPENDED_PENDING,
1353
                      TERMINATED_PENDING,
1354
                      ]
1355

    
1356
    SKIP_STATES = [DISMISSED,
1357
                   CANCELLED,
1358
                   TERMINATED]
1359

    
1360
    STATE_DISPLAY = {
1361
        PENDING            : _("Pending"),
1362
        DENIED             : _("Denied"),
1363
        DISMISSED          : _("Dismissed"),
1364
        CANCELLED          : _("Cancelled"),
1365
        APPROVED           : _("Active"),
1366
        APPROVED_PENDING   : _("Active - Pending"),
1367
        SUSPENDED          : _("Suspended"),
1368
        SUSPENDED_PENDING  : _("Suspended - Pending"),
1369
        TERMINATED         : _("Terminated"),
1370
        TERMINATED_PENDING : _("Terminated - Pending"),
1371
        }
1372

    
1373

    
1374
    @classmethod
1375
    def _chain_state(cls, project_state, app_state):
1376
        s = CHAIN_STATE.get((project_state, app_state), None)
1377
        if s is None:
1378
            raise AssertionError('inconsistent chain state')
1379
        return s
1380

    
1381
    @classmethod
1382
    def chain_state(cls, project, app):
1383
        p_state = project.state if project else None
1384
        return cls._chain_state(p_state, app.state)
1385

    
1386
    @classmethod
1387
    def state_display(cls, s):
1388
        if s is None:
1389
            return _("Unknown")
1390
        return cls.STATE_DISPLAY.get(s, _("Inconsistent"))
1391

    
1392
    def last_application(self):
1393
        return self.chained_apps.order_by('-id')[0]
1394

    
1395
    def get_project(self):
1396
        try:
1397
            return self.chained_project
1398
        except Project.DoesNotExist:
1399
            return None
1400

    
1401
    def get_elements(self):
1402
        project = self.get_project()
1403
        app = self.last_application()
1404
        return project, app
1405

    
1406
    def full_state(self):
1407
        project, app = self.get_elements()
1408
        s = self.chain_state(project, app)
1409
        return s, project, app
1410

    
1411
def new_chain():
1412
    c = Chain.objects.create()
1413
    return c
1414

    
1415

    
1416
class ProjectApplicationManager(ForUpdateManager):
1417

    
1418
    def user_visible_projects(self, *filters, **kw_filters):
1419
        model = self.model
1420
        return self.filter(model.Q_PENDING | model.Q_APPROVED)
1421

    
1422
    def user_visible_by_chain(self, flt):
1423
        model = self.model
1424
        pending = self.filter(model.Q_PENDING | model.Q_DENIED).values_list('chain')
1425
        approved = self.filter(model.Q_APPROVED).values_list('chain')
1426
        by_chain = dict(pending.annotate(models.Max('id')))
1427
        by_chain.update(approved.annotate(models.Max('id')))
1428
        return self.filter(flt, id__in=by_chain.values())
1429

    
1430
    def user_accessible_projects(self, user):
1431
        """
1432
        Return projects accessed by specified user.
1433
        """
1434
        if user.is_project_admin():
1435
            participates_filters = Q()
1436
        else:
1437
            participates_filters = Q(owner=user) | Q(applicant=user) | \
1438
                                   Q(project__projectmembership__person=user)
1439

    
1440
        return self.user_visible_by_chain(participates_filters).order_by('issue_date').distinct()
1441

    
1442
    def search_by_name(self, *search_strings):
1443
        q = Q()
1444
        for s in search_strings:
1445
            q = q | Q(name__icontains=s)
1446
        return self.filter(q)
1447

    
1448
    def latest_of_chain(self, chain_id):
1449
        try:
1450
            return self.filter(chain=chain_id).order_by('-id')[0]
1451
        except IndexError:
1452
            return None
1453

    
1454

    
1455
class ProjectApplication(models.Model):
1456
    applicant               =   models.ForeignKey(
1457
                                    AstakosUser,
1458
                                    related_name='projects_applied',
1459
                                    db_index=True)
1460

    
1461
    PENDING     =    0
1462
    APPROVED    =    1
1463
    REPLACED    =    2
1464
    DENIED      =    3
1465
    DISMISSED   =    4
1466
    CANCELLED   =    5
1467

    
1468
    state                   =   models.IntegerField(default=PENDING,
1469
                                                    db_index=True)
1470

    
1471
    owner                   =   models.ForeignKey(
1472
                                    AstakosUser,
1473
                                    related_name='projects_owned',
1474
                                    db_index=True)
1475

    
1476
    chain                   =   models.ForeignKey(Chain,
1477
                                                  related_name='chained_apps',
1478
                                                  db_column='chain')
1479
    precursor_application   =   models.ForeignKey('ProjectApplication',
1480
                                                  null=True,
1481
                                                  blank=True)
1482

    
1483
    name                    =   models.CharField(max_length=80)
1484
    homepage                =   models.URLField(max_length=255, null=True,
1485
                                                verify_exists=False)
1486
    description             =   models.TextField(null=True, blank=True)
1487
    start_date              =   models.DateTimeField(null=True, blank=True)
1488
    end_date                =   models.DateTimeField()
1489
    member_join_policy      =   models.IntegerField()
1490
    member_leave_policy     =   models.IntegerField()
1491
    limit_on_members_number =   models.PositiveIntegerField(null=True)
1492
    resource_grants         =   models.ManyToManyField(
1493
                                    Resource,
1494
                                    null=True,
1495
                                    blank=True,
1496
                                    through='ProjectResourceGrant')
1497
    comments                =   models.TextField(null=True, blank=True)
1498
    issue_date              =   models.DateTimeField(auto_now_add=True)
1499
    response_date           =   models.DateTimeField(null=True, blank=True)
1500

    
1501
    objects                 =   ProjectApplicationManager()
1502

    
1503
    # Compiled queries
1504
    Q_PENDING  = Q(state=PENDING)
1505
    Q_APPROVED = Q(state=APPROVED)
1506
    Q_DENIED   = Q(state=DENIED)
1507

    
1508
    class Meta:
1509
        unique_together = ("chain", "id")
1510

    
1511
    def __unicode__(self):
1512
        return "%s applied by %s" % (self.name, self.applicant)
1513

    
1514
    # TODO: Move to a more suitable place
1515
    APPLICATION_STATE_DISPLAY = {
1516
        PENDING  : _('Pending review'),
1517
        APPROVED : _('Approved'),
1518
        REPLACED : _('Replaced'),
1519
        DENIED   : _('Denied'),
1520
        DISMISSED: _('Dismissed'),
1521
        CANCELLED: _('Cancelled')
1522
    }
1523

    
1524
    def get_project(self):
1525
        try:
1526
            project = Project.objects.get(id=self.chain, state=Project.APPROVED)
1527
            return Project
1528
        except Project.DoesNotExist, e:
1529
            return None
1530

    
1531
    def state_display(self):
1532
        return self.APPLICATION_STATE_DISPLAY.get(self.state, _('Unknown'))
1533

    
1534
    def project_state_display(self):
1535
        try:
1536
            project = self.project
1537
            return project.state_display()
1538
        except Project.DoesNotExist:
1539
            return self.state_display()
1540

    
1541
    def add_resource_policy(self, service, resource, uplimit):
1542
        """Raises ObjectDoesNotExist, IntegrityError"""
1543
        q = self.projectresourcegrant_set
1544
        resource = Resource.objects.get(service__name=service, name=resource)
1545
        q.create(resource=resource, member_capacity=uplimit)
1546

    
1547
    def members_count(self):
1548
        return self.project.approved_memberships.count()
1549

    
1550
    @property
1551
    def grants(self):
1552
        return self.projectresourcegrant_set.values(
1553
            'member_capacity', 'resource__name', 'resource__service__name')
1554

    
1555
    @property
1556
    def resource_policies(self):
1557
        return [str(rp) for rp in self.projectresourcegrant_set.all()]
1558

    
1559
    @resource_policies.setter
1560
    def resource_policies(self, policies):
1561
        for p in policies:
1562
            service = p.get('service', None)
1563
            resource = p.get('resource', None)
1564
            uplimit = p.get('uplimit', 0)
1565
            self.add_resource_policy(service, resource, uplimit)
1566

    
1567
    def pending_modifications_incl_me(self):
1568
        q = self.chained_applications()
1569
        q = q.filter(Q(state=self.PENDING))
1570
        return q
1571

    
1572
    def last_pending_incl_me(self):
1573
        try:
1574
            return self.pending_modifications_incl_me().order_by('-id')[0]
1575
        except IndexError:
1576
            return None
1577

    
1578
    def pending_modifications(self):
1579
        return self.pending_modifications_incl_me().filter(~Q(id=self.id))
1580

    
1581
    def last_pending(self):
1582
        try:
1583
            return self.pending_modifications().order_by('-id')[0]
1584
        except IndexError:
1585
            return None
1586

    
1587
    def is_modification(self):
1588
        # if self.state != self.PENDING:
1589
        #     return False
1590
        parents = self.chained_applications().filter(id__lt=self.id)
1591
        parents = parents.filter(state__in=[self.APPROVED])
1592
        return parents.count() > 0
1593

    
1594
    def chained_applications(self):
1595
        return ProjectApplication.objects.filter(chain=self.chain)
1596

    
1597
    def is_latest(self):
1598
        return self.chained_applications().order_by('-id')[0] == self
1599

    
1600
    def has_pending_modifications(self):
1601
        return bool(self.last_pending())
1602

    
1603
    def denied_modifications(self):
1604
        q = self.chained_applications()
1605
        q = q.filter(Q(state=self.DENIED))
1606
        q = q.filter(~Q(id=self.id))
1607
        return q
1608

    
1609
    def last_denied(self):
1610
        try:
1611
            return self.denied_modifications().order_by('-id')[0]
1612
        except IndexError:
1613
            return None
1614

    
1615
    def has_denied_modifications(self):
1616
        return bool(self.last_denied())
1617

    
1618
    def is_applied(self):
1619
        try:
1620
            self.project
1621
            return True
1622
        except Project.DoesNotExist:
1623
            return False
1624

    
1625
    def get_project(self):
1626
        try:
1627
            return Project.objects.get(id=self.chain)
1628
        except Project.DoesNotExist:
1629
            return None
1630

    
1631
    def project_exists(self):
1632
        return self.get_project() is not None
1633

    
1634
    def _get_project_for_update(self):
1635
        try:
1636
            objects = Project.objects.select_for_update()
1637
            project = objects.get(id=self.chain)
1638
            return project
1639
        except Project.DoesNotExist:
1640
            return None
1641

    
1642
    def can_cancel(self):
1643
        return self.state == self.PENDING
1644

    
1645
    def cancel(self):
1646
        if not self.can_cancel():
1647
            m = _("cannot cancel: application '%s' in state '%s'") % (
1648
                    self.id, self.state)
1649
            raise AssertionError(m)
1650

    
1651
        self.state = self.CANCELLED
1652
        self.save()
1653

    
1654
    def can_dismiss(self):
1655
        return self.state == self.DENIED
1656

    
1657
    def dismiss(self):
1658
        if not self.can_dismiss():
1659
            m = _("cannot dismiss: application '%s' in state '%s'") % (
1660
                    self.id, self.state)
1661
            raise AssertionError(m)
1662

    
1663
        self.state = self.DISMISSED
1664
        self.save()
1665

    
1666
    def can_deny(self):
1667
        return self.state == self.PENDING
1668

    
1669
    def deny(self):
1670
        if not self.can_deny():
1671
            m = _("cannot deny: application '%s' in state '%s'") % (
1672
                    self.id, self.state)
1673
            raise AssertionError(m)
1674

    
1675
        self.state = self.DENIED
1676
        self.response_date = datetime.now()
1677
        self.save()
1678

    
1679
    def can_approve(self):
1680
        return self.state == self.PENDING
1681

    
1682
    def approve(self, approval_user=None):
1683
        """
1684
        If approval_user then during owner membership acceptance
1685
        it is checked whether the request_user is eligible.
1686

1687
        Raises:
1688
            PermissionDenied
1689
        """
1690

    
1691
        if not transaction.is_managed():
1692
            raise AssertionError("NOPE")
1693

    
1694
        new_project_name = self.name
1695
        if not self.can_approve():
1696
            m = _("cannot approve: project '%s' in state '%s'") % (
1697
                    new_project_name, self.state)
1698
            raise AssertionError(m) # invalid argument
1699

    
1700
        now = datetime.now()
1701
        project = self._get_project_for_update()
1702

    
1703
        try:
1704
            q = Q(name=new_project_name) & ~Q(state=Project.TERMINATED)
1705
            conflicting_project = Project.objects.get(q)
1706
            if (conflicting_project != project):
1707
                m = (_("cannot approve: project with name '%s' "
1708
                       "already exists (id: %s)") % (
1709
                        new_project_name, conflicting_project.id))
1710
                raise PermissionDenied(m) # invalid argument
1711
        except Project.DoesNotExist:
1712
            pass
1713

    
1714
        new_project = False
1715
        if project is None:
1716
            new_project = True
1717
            project = Project(id=self.chain)
1718

    
1719
        project.name = new_project_name
1720
        project.application = self
1721
        project.last_approval_date = now
1722
        if not new_project:
1723
            project.is_modified = True
1724

    
1725
        project.save()
1726

    
1727
        self.state = self.APPROVED
1728
        self.response_date = now
1729
        self.save()
1730

    
1731
    @property
1732
    def member_join_policy_display(self):
1733
        return PROJECT_MEMBER_JOIN_POLICIES.get(str(self.member_join_policy))
1734

    
1735
    @property
1736
    def member_leave_policy_display(self):
1737
        return PROJECT_MEMBER_LEAVE_POLICIES.get(str(self.member_leave_policy))
1738

    
1739
class ProjectResourceGrant(models.Model):
1740

    
1741
    resource                =   models.ForeignKey(Resource)
1742
    project_application     =   models.ForeignKey(ProjectApplication,
1743
                                                  null=True)
1744
    project_capacity        =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1745
    project_import_limit    =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1746
    project_export_limit    =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1747
    member_capacity         =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1748
    member_import_limit     =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1749
    member_export_limit     =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1750

    
1751
    objects = ExtendedManager()
1752

    
1753
    class Meta:
1754
        unique_together = ("resource", "project_application")
1755

    
1756
    def member_quota_values(self):
1757
        return QuotaValues(
1758
            quantity = 0,
1759
            capacity = self.member_capacity,
1760
            import_limit = self.member_import_limit,
1761
            export_limit = self.member_export_limit)
1762

    
1763
    def display_member_capacity(self):
1764
        if self.member_capacity:
1765
            if self.resource.unit:
1766
                return ProjectResourceGrant.display_filesize(
1767
                    self.member_capacity)
1768
            else:
1769
                if math.isinf(self.member_capacity):
1770
                    return 'Unlimited'
1771
                else:
1772
                    return self.member_capacity
1773
        else:
1774
            return 'Unlimited'
1775

    
1776
    def __str__(self):
1777
        return 'Max %s per user: %s' % (self.resource.pluralized_display_name,
1778
                                        self.display_member_capacity())
1779

    
1780
    @classmethod
1781
    def display_filesize(cls, value):
1782
        try:
1783
            value = float(value)
1784
        except:
1785
            return
1786
        else:
1787
            if math.isinf(value):
1788
                return 'Unlimited'
1789
            if value > 1:
1790
                unit_list = zip(['bytes', 'kB', 'MB', 'GB', 'TB', 'PB'],
1791
                                [0, 0, 0, 0, 0, 0])
1792
                exponent = min(int(math.log(value, 1024)), len(unit_list) - 1)
1793
                quotient = float(value) / 1024**exponent
1794
                unit, value_decimals = unit_list[exponent]
1795
                format_string = '{0:.%sf} {1}' % (value_decimals)
1796
                return format_string.format(quotient, unit)
1797
            if value == 0:
1798
                return '0 bytes'
1799
            if value == 1:
1800
                return '1 byte'
1801
            else:
1802
               return '0'
1803

    
1804

    
1805
class ProjectManager(ForUpdateManager):
1806

    
1807
    def terminated_projects(self):
1808
        q = self.model.Q_TERMINATED
1809
        return self.filter(q)
1810

    
1811
    def not_terminated_projects(self):
1812
        q = ~self.model.Q_TERMINATED
1813
        return self.filter(q)
1814

    
1815
    def terminating_projects(self):
1816
        q = self.model.Q_TERMINATED & Q(is_active=True)
1817
        return self.filter(q)
1818

    
1819
    def deactivated_projects(self):
1820
        q = self.model.Q_DEACTIVATED
1821
        return self.filter(q)
1822

    
1823
    def deactivating_projects(self):
1824
        q = self.model.Q_DEACTIVATED & Q(is_active=True)
1825
        return self.filter(q)
1826

    
1827
    def modified_projects(self):
1828
        return self.filter(is_modified=True)
1829

    
1830
    def reactivating_projects(self):
1831
        return self.filter(state=Project.APPROVED, is_active=False)
1832

    
1833
    def expired_projects(self):
1834
        q = (~Q(state=Project.TERMINATED) &
1835
              Q(application__end_date__lt=datetime.now()))
1836
        return self.filter(q)
1837

    
1838
    def search_by_name(self, *search_strings):
1839
        q = Q()
1840
        for s in search_strings:
1841
            q = q | Q(name__icontains=s)
1842
        return self.filter(q)
1843

    
1844

    
1845
class Project(models.Model):
1846

    
1847
    id                          =   models.OneToOneField(Chain,
1848
                                                      related_name='chained_project',
1849
                                                      db_column='id',
1850
                                                      primary_key=True)
1851

    
1852
    application                 =   models.OneToOneField(
1853
                                            ProjectApplication,
1854
                                            related_name='project')
1855
    last_approval_date          =   models.DateTimeField(null=True)
1856

    
1857
    members                     =   models.ManyToManyField(
1858
                                            AstakosUser,
1859
                                            through='ProjectMembership')
1860

    
1861
    deactivation_reason         =   models.CharField(max_length=255, null=True)
1862
    deactivation_date           =   models.DateTimeField(null=True)
1863

    
1864
    creation_date               =   models.DateTimeField(auto_now_add=True)
1865
    name                        =   models.CharField(
1866
                                            max_length=80,
1867
                                            null=True,
1868
                                            db_index=True,
1869
                                            unique=True)
1870

    
1871
    APPROVED    = 1
1872
    SUSPENDED   = 10
1873
    TERMINATED  = 100
1874

    
1875
    is_modified                 =   models.BooleanField(default=False,
1876
                                                        db_index=True)
1877
    is_active                   =   models.BooleanField(default=True,
1878
                                                        db_index=True)
1879
    state                       =   models.IntegerField(default=APPROVED,
1880
                                                        db_index=True)
1881

    
1882
    objects     =   ProjectManager()
1883

    
1884
    # Compiled queries
1885
    Q_TERMINATED  = Q(state=TERMINATED)
1886
    Q_SUSPENDED   = Q(state=SUSPENDED)
1887
    Q_DEACTIVATED = Q_TERMINATED | Q_SUSPENDED
1888

    
1889
    def __str__(self):
1890
        return _("<project %s '%s'>") % (self.id, self.application.name)
1891

    
1892
    __repr__ = __str__
1893

    
1894
    STATE_DISPLAY = {
1895
        APPROVED   : 'Active',
1896
        SUSPENDED  : 'Suspended',
1897
        TERMINATED : 'Terminated'
1898
        }
1899

    
1900
    def state_display(self):
1901
        return self.STATE_DISPLAY.get(self.state, _('Unknown'))
1902

    
1903
    def admin_state_display(self):
1904
        s = self.state_display()
1905
        if self.sync_pending():
1906
            s += ' (sync pending)'
1907
        return s
1908

    
1909
    def sync_pending(self):
1910
        if self.state != self.APPROVED:
1911
            return self.is_active
1912
        return not self.is_active or self.is_modified
1913

    
1914
    def expiration_info(self):
1915
        return (str(self.id), self.name, self.state_display(),
1916
                str(self.application.end_date))
1917

    
1918
    def is_deactivated(self, reason=None):
1919
        if reason is not None:
1920
            return self.state == reason
1921

    
1922
        return self.state != self.APPROVED
1923

    
1924
    def is_deactivating(self, reason=None):
1925
        if not self.is_active:
1926
            return False
1927

    
1928
        return self.is_deactivated(reason)
1929

    
1930
    def is_deactivated_strict(self, reason=None):
1931
        if self.is_active:
1932
            return False
1933

    
1934
        return self.is_deactivated(reason)
1935

    
1936
    ### Deactivation calls
1937

    
1938
    def deactivate(self):
1939
        self.deactivation_date = datetime.now()
1940
        self.is_active = False
1941

    
1942
    def reactivate(self):
1943
        self.deactivation_date = None
1944
        self.is_active = True
1945

    
1946
    def terminate(self):
1947
        self.deactivation_reason = 'TERMINATED'
1948
        self.state = self.TERMINATED
1949
        self.name = None
1950
        self.save()
1951

    
1952
    def suspend(self):
1953
        self.deactivation_reason = 'SUSPENDED'
1954
        self.state = self.SUSPENDED
1955
        self.save()
1956

    
1957
    def resume(self):
1958
        self.deactivation_reason = None
1959
        self.state = self.APPROVED
1960
        self.save()
1961

    
1962
    ### Logical checks
1963

    
1964
    def is_inconsistent(self):
1965
        now = datetime.now()
1966
        dates = [self.creation_date,
1967
                 self.last_approval_date,
1968
                 self.deactivation_date]
1969
        return any([date > now for date in dates])
1970

    
1971
    def is_active_strict(self):
1972
        return self.is_active and self.state == self.APPROVED
1973

    
1974
    def is_approved(self):
1975
        return self.state == self.APPROVED
1976

    
1977
    @property
1978
    def is_alive(self):
1979
        return not self.is_terminated
1980

    
1981
    @property
1982
    def is_terminated(self):
1983
        return self.is_deactivated(self.TERMINATED)
1984

    
1985
    @property
1986
    def is_suspended(self):
1987
        return self.is_deactivated(self.SUSPENDED)
1988

    
1989
    def violates_resource_grants(self):
1990
        return False
1991

    
1992
    def violates_members_limit(self, adding=0):
1993
        application = self.application
1994
        limit = application.limit_on_members_number
1995
        if limit is None:
1996
            return False
1997
        return (len(self.approved_members) + adding > limit)
1998

    
1999

    
2000
    ### Other
2001

    
2002
    def count_pending_memberships(self):
2003
        memb_set = self.projectmembership_set
2004
        memb_count = memb_set.filter(state=ProjectMembership.REQUESTED).count()
2005
        return memb_count
2006

    
2007
    def members_count(self):
2008
        return self.approved_memberships.count()
2009

    
2010
    @property
2011
    def approved_memberships(self):
2012
        query = ProjectMembership.Q_ACCEPTED_STATES
2013
        return self.projectmembership_set.filter(query)
2014

    
2015
    @property
2016
    def approved_members(self):
2017
        return [m.person for m in self.approved_memberships]
2018

    
2019
    def add_member(self, user):
2020
        """
2021
        Raises:
2022
            django.exceptions.PermissionDenied
2023
            astakos.im.models.AstakosUser.DoesNotExist
2024
        """
2025
        if isinstance(user, int):
2026
            user = AstakosUser.objects.get(user=user)
2027

    
2028
        m, created = ProjectMembership.objects.get_or_create(
2029
            person=user, project=self
2030
        )
2031
        m.accept()
2032

    
2033
    def remove_member(self, user):
2034
        """
2035
        Raises:
2036
            django.exceptions.PermissionDenied
2037
            astakos.im.models.AstakosUser.DoesNotExist
2038
            astakos.im.models.ProjectMembership.DoesNotExist
2039
        """
2040
        if isinstance(user, int):
2041
            user = AstakosUser.objects.get(user=user)
2042

    
2043
        m = ProjectMembership.objects.get(person=user, project=self)
2044
        m.remove()
2045

    
2046

    
2047
CHAIN_STATE = {
2048
    (Project.APPROVED,   ProjectApplication.PENDING)  : Chain.APPROVED_PENDING,
2049
    (Project.APPROVED,   ProjectApplication.APPROVED) : Chain.APPROVED,
2050
    (Project.APPROVED,   ProjectApplication.DENIED)   : Chain.APPROVED,
2051
    (Project.APPROVED,   ProjectApplication.DISMISSED): Chain.APPROVED,
2052
    (Project.APPROVED,   ProjectApplication.CANCELLED): Chain.APPROVED,
2053

    
2054
    (Project.SUSPENDED,  ProjectApplication.PENDING)  : Chain.SUSPENDED_PENDING,
2055
    (Project.SUSPENDED,  ProjectApplication.APPROVED) : Chain.SUSPENDED,
2056
    (Project.SUSPENDED,  ProjectApplication.DENIED)   : Chain.SUSPENDED,
2057
    (Project.SUSPENDED,  ProjectApplication.DISMISSED): Chain.SUSPENDED,
2058
    (Project.SUSPENDED,  ProjectApplication.CANCELLED): Chain.SUSPENDED,
2059

    
2060
    (Project.TERMINATED, ProjectApplication.PENDING)  : Chain.TERMINATED_PENDING,
2061
    (Project.TERMINATED, ProjectApplication.APPROVED) : Chain.TERMINATED,
2062
    (Project.TERMINATED, ProjectApplication.DENIED)   : Chain.TERMINATED,
2063
    (Project.TERMINATED, ProjectApplication.DISMISSED): Chain.TERMINATED,
2064
    (Project.TERMINATED, ProjectApplication.CANCELLED): Chain.TERMINATED,
2065

    
2066
    (None,               ProjectApplication.PENDING)  : Chain.PENDING,
2067
    (None,               ProjectApplication.DENIED)   : Chain.DENIED,
2068
    (None,               ProjectApplication.DISMISSED): Chain.DISMISSED,
2069
    (None,               ProjectApplication.CANCELLED): Chain.CANCELLED,
2070
    }
2071

    
2072

    
2073
class PendingMembershipError(Exception):
2074
    pass
2075

    
2076

    
2077
class ProjectMembershipManager(ForUpdateManager):
2078

    
2079
    def any_accepted(self):
2080
        q = (Q(state=ProjectMembership.ACCEPTED) |
2081
             Q(state=ProjectMembership.PROJECT_DEACTIVATED))
2082
        return self.filter(q)
2083

    
2084
    def actually_accepted(self):
2085
        q = self.model.Q_ACTUALLY_ACCEPTED
2086
        return self.filter(q)
2087

    
2088
    def requested(self):
2089
        return self.filter(state=ProjectMembership.REQUESTED)
2090

    
2091
    def suspended(self):
2092
        return self.filter(state=ProjectMembership.USER_SUSPENDED)
2093

    
2094
class ProjectMembership(models.Model):
2095

    
2096
    person              =   models.ForeignKey(AstakosUser)
2097
    request_date        =   models.DateField(auto_now_add=True)
2098
    project             =   models.ForeignKey(Project)
2099

    
2100
    REQUESTED           =   0
2101
    ACCEPTED            =   1
2102
    LEAVE_REQUESTED     =   5
2103
    # User deactivation
2104
    USER_SUSPENDED      =   10
2105
    # Project deactivation
2106
    PROJECT_DEACTIVATED =   100
2107

    
2108
    REMOVED             =   200
2109

    
2110
    ASSOCIATED_STATES   =   set([REQUESTED,
2111
                                 ACCEPTED,
2112
                                 LEAVE_REQUESTED,
2113
                                 USER_SUSPENDED,
2114
                                 PROJECT_DEACTIVATED])
2115

    
2116
    ACCEPTED_STATES     =   set([ACCEPTED,
2117
                                 LEAVE_REQUESTED,
2118
                                 USER_SUSPENDED,
2119
                                 PROJECT_DEACTIVATED])
2120

    
2121
    ACTUALLY_ACCEPTED   =   set([ACCEPTED, LEAVE_REQUESTED])
2122

    
2123
    state               =   models.IntegerField(default=REQUESTED,
2124
                                                db_index=True)
2125
    is_pending          =   models.BooleanField(default=False, db_index=True)
2126
    is_active           =   models.BooleanField(default=False, db_index=True)
2127
    application         =   models.ForeignKey(
2128
                                ProjectApplication,
2129
                                null=True,
2130
                                related_name='memberships')
2131
    pending_application =   models.ForeignKey(
2132
                                ProjectApplication,
2133
                                null=True,
2134
                                related_name='pending_memberships')
2135
    pending_serial      =   models.BigIntegerField(null=True, db_index=True)
2136

    
2137
    acceptance_date     =   models.DateField(null=True, db_index=True)
2138
    leave_request_date  =   models.DateField(null=True)
2139

    
2140
    objects     =   ProjectMembershipManager()
2141

    
2142
    # Compiled queries
2143
    Q_ACCEPTED_STATES = ~Q(state=REQUESTED) & ~Q(state=REMOVED)
2144
    Q_ACTUALLY_ACCEPTED = Q(state=ACCEPTED) | Q(state=LEAVE_REQUESTED)
2145

    
2146
    MEMBERSHIP_STATE_DISPLAY = {
2147
        REQUESTED           : _('Requested'),
2148
        ACCEPTED            : _('Accepted'),
2149
        LEAVE_REQUESTED     : _('Leave Requested'),
2150
        USER_SUSPENDED      : _('Suspended'),
2151
        PROJECT_DEACTIVATED : _('Accepted'), # sic
2152
        REMOVED             : _('Pending removal'),
2153
        }
2154

    
2155
    USER_FRIENDLY_STATE_DISPLAY = {
2156
        REQUESTED           : _('Join requested'),
2157
        ACCEPTED            : _('Accepted member'),
2158
        LEAVE_REQUESTED     : _('Requested to leave'),
2159
        USER_SUSPENDED      : _('Suspended member'),
2160
        PROJECT_DEACTIVATED : _('Accepted member'), # sic
2161
        REMOVED             : _('Pending removal'),
2162
        }
2163

    
2164
    def state_display(self):
2165
        return self.MEMBERSHIP_STATE_DISPLAY.get(self.state, _('Unknown'))
2166

    
2167
    def user_friendly_state_display(self):
2168
        return self.USER_FRIENDLY_STATE_DISPLAY.get(self.state, _('Unknown'))
2169

    
2170
    def get_combined_state(self):
2171
        return self.state, self.is_active, self.is_pending
2172

    
2173
    class Meta:
2174
        unique_together = ("person", "project")
2175
        #index_together = [["project", "state"]]
2176

    
2177
    def __str__(self):
2178
        return _("<'%s' membership in '%s'>") % (
2179
                self.person.username, self.project)
2180

    
2181
    __repr__ = __str__
2182

    
2183
    def __init__(self, *args, **kwargs):
2184
        self.state = self.REQUESTED
2185
        super(ProjectMembership, self).__init__(*args, **kwargs)
2186

    
2187
    def _set_history_item(self, reason, date=None):
2188
        if isinstance(reason, basestring):
2189
            reason = ProjectMembershipHistory.reasons.get(reason, -1)
2190

    
2191
        history_item = ProjectMembershipHistory(
2192
                            serial=self.id,
2193
                            person=self.person_id,
2194
                            project=self.project_id,
2195
                            date=date or datetime.now(),
2196
                            reason=reason)
2197
        history_item.save()
2198
        serial = history_item.id
2199

    
2200
    def can_accept(self):
2201
        return self.state == self.REQUESTED
2202

    
2203
    def accept(self):
2204
        if self.is_pending:
2205
            m = _("%s: attempt to accept while is pending") % (self,)
2206
            raise AssertionError(m)
2207

    
2208
        if not self.can_accept():
2209
            m = _("%s: attempt to accept in state '%s'") % (self, self.state)
2210
            raise AssertionError(m)
2211

    
2212
        now = datetime.now()
2213
        self.acceptance_date = now
2214
        self._set_history_item(reason='ACCEPT', date=now)
2215
        if self.project.is_approved():
2216
            self.state = self.ACCEPTED
2217
            self.is_pending = True
2218
        else:
2219
            self.state = self.PROJECT_DEACTIVATED
2220

    
2221
        self.save()
2222

    
2223
    def can_leave(self):
2224
        return self.state in self.ACCEPTED_STATES
2225

    
2226
    def leave_request(self):
2227
        if self.is_pending:
2228
            m = _("%s: attempt to request to leave while is pending") % (self,)
2229
            raise AssertionError(m)
2230

    
2231
        if not self.can_leave():
2232
            m = _("%s: attempt to request to leave in state '%s'") % (
2233
                self, self.state)
2234
            raise AssertionError(m)
2235

    
2236
        self.leave_request_date = datetime.now()
2237
        self.state = self.LEAVE_REQUESTED
2238
        self.save()
2239

    
2240
    def can_deny_leave(self):
2241
        return self.state == self.LEAVE_REQUESTED
2242

    
2243
    def leave_request_deny(self):
2244
        if self.is_pending:
2245
            m = _("%s: attempt to deny leave request while is pending") % (
2246
                self,)
2247
            raise AssertionError(m)
2248

    
2249
        if not self.can_deny_leave():
2250
            m = _("%s: attempt to deny leave request in state '%s'") % (
2251
                self, self.state)
2252
            raise AssertionError(m)
2253

    
2254
        self.leave_request_date = None
2255
        self.state = self.ACCEPTED
2256
        self.save()
2257

    
2258
    def can_cancel_leave(self):
2259
        return self.state == self.LEAVE_REQUESTED
2260

    
2261
    def leave_request_cancel(self):
2262
        if self.is_pending:
2263
            m = _("%s: attempt to cancel leave request while is pending") % (
2264
                self,)
2265
            raise AssertionError(m)
2266

    
2267
        if not self.can_cancel_leave():
2268
            m = _("%s: attempt to cancel leave request in state '%s'") % (
2269
                self, self.state)
2270
            raise AssertionError(m)
2271

    
2272
        self.leave_request_date = None
2273
        self.state = self.ACCEPTED
2274
        self.save()
2275

    
2276
    def can_remove(self):
2277
        return self.state in self.ACCEPTED_STATES
2278

    
2279
    def remove(self):
2280
        if self.is_pending:
2281
            m = _("%s: attempt to remove while is pending") % (self,)
2282
            raise AssertionError(m)
2283

    
2284
        if not self.can_remove():
2285
            m = _("%s: attempt to remove in state '%s'") % (self, self.state)
2286
            raise AssertionError(m)
2287

    
2288
        self._set_history_item(reason='REMOVE')
2289
        self.state = self.REMOVED
2290
        self.is_pending = True
2291
        self.save()
2292

    
2293
    def can_reject(self):
2294
        return self.state == self.REQUESTED
2295

    
2296
    def reject(self):
2297
        if self.is_pending:
2298
            m = _("%s: attempt to reject while is pending") % (self,)
2299
            raise AssertionError(m)
2300

    
2301
        if not self.can_reject():
2302
            m = _("%s: attempt to reject in state '%s'") % (self, self.state)
2303
            raise AssertionError(m)
2304

    
2305
        # rejected requests don't need sync,
2306
        # because they were never effected
2307
        self._set_history_item(reason='REJECT')
2308
        self.delete()
2309

    
2310
    def can_cancel(self):
2311
        return self.state == self.REQUESTED
2312

    
2313
    def cancel(self):
2314
        if self.is_pending:
2315
            m = _("%s: attempt to cancel while is pending") % (self,)
2316
            raise AssertionError(m)
2317

    
2318
        if not self.can_cancel():
2319
            m = _("%s: attempt to cancel in state '%s'") % (self, self.state)
2320
            raise AssertionError(m)
2321

    
2322
        # rejected requests don't need sync,
2323
        # because they were never effected
2324
        self._set_history_item(reason='CANCEL')
2325
        self.delete()
2326

    
2327
    def get_diff_quotas(self, sub_list=None, add_list=None):
2328
        if sub_list is None:
2329
            sub_list = []
2330

    
2331
        if add_list is None:
2332
            add_list = []
2333

    
2334
        sub_append = sub_list.append
2335
        add_append = add_list.append
2336
        holder = self.person.uuid
2337

    
2338
        synced_application = self.application
2339
        if synced_application is not None:
2340
            cur_grants = synced_application.projectresourcegrant_set.all()
2341
            for grant in cur_grants:
2342
                sub_append(QuotaLimits(
2343
                               holder       = holder,
2344
                               resource     = str(grant.resource),
2345
                               capacity     = grant.member_capacity,
2346
                               import_limit = grant.member_import_limit,
2347
                               export_limit = grant.member_export_limit))
2348

    
2349
        pending_application = self.pending_application
2350
        if pending_application is not None:
2351
            new_grants = pending_application.projectresourcegrant_set.all()
2352
            for new_grant in new_grants:
2353
                add_append(QuotaLimits(
2354
                               holder       = holder,
2355
                               resource     = str(new_grant.resource),
2356
                               capacity     = new_grant.member_capacity,
2357
                               import_limit = new_grant.member_import_limit,
2358
                               export_limit = new_grant.member_export_limit))
2359

    
2360
        return (sub_list, add_list)
2361

    
2362
    def set_sync(self):
2363
        if not self.is_pending:
2364
            m = _("%s: attempt to sync a non pending membership") % (self,)
2365
            raise AssertionError(m)
2366

    
2367
        state = self.state
2368
        if state in self.ACTUALLY_ACCEPTED:
2369
            pending_application = self.pending_application
2370
            if pending_application is None:
2371
                m = _("%s: attempt to sync an empty pending application") % (
2372
                    self,)
2373
                raise AssertionError(m)
2374

    
2375
            self.application = pending_application
2376
            self.is_active = True
2377

    
2378
            self.pending_application = None
2379
            self.pending_serial = None
2380

    
2381
            # project.application may have changed in the meantime,
2382
            # in which case we stay PENDING;
2383
            # we are safe to check due to select_for_update
2384
            if self.application == self.project.application:
2385
                self.is_pending = False
2386
            self.save()
2387

    
2388
        elif state == self.PROJECT_DEACTIVATED:
2389
            if self.pending_application:
2390
                m = _("%s: attempt to sync in state '%s' "
2391
                      "with a pending application") % (self, state)
2392
                raise AssertionError(m)
2393

    
2394
            self.application = None
2395
            self.is_active = False
2396
            self.pending_serial = None
2397
            self.is_pending = False
2398
            self.save()
2399

    
2400
        elif state == self.REMOVED:
2401
            self.delete()
2402

    
2403
        else:
2404
            m = _("%s: attempt to sync in state '%s'") % (self, state)
2405
            raise AssertionError(m)
2406

    
2407
    def reset_sync(self):
2408
        if not self.is_pending:
2409
            m = _("%s: attempt to reset a non pending membership") % (self,)
2410
            raise AssertionError(m)
2411

    
2412
        state = self.state
2413
        if state in [self.ACCEPTED, self.LEAVE_REQUESTED,
2414
                     self.PROJECT_DEACTIVATED, self.REMOVED]:
2415
            self.pending_application = None
2416
            self.pending_serial = None
2417
            self.save()
2418
        else:
2419
            m = _("%s: attempt to reset sync in state '%s'") % (self, state)
2420
            raise AssertionError(m)
2421

    
2422
class Serial(models.Model):
2423
    serial  =   models.AutoField(primary_key=True)
2424

    
2425
def new_serial():
2426
    s = Serial.objects.create()
2427
    serial = s.serial
2428
    s.delete()
2429
    return serial
2430

    
2431
def sync_finish_serials(serials_to_ack=None):
2432
    if serials_to_ack is None:
2433
        serials_to_ack = qh_query_serials([])
2434

    
2435
    serials_to_ack = set(serials_to_ack)
2436
    sfu = ProjectMembership.objects.select_for_update()
2437
    memberships = list(sfu.filter(pending_serial__isnull=False))
2438

    
2439
    if memberships:
2440
        for membership in memberships:
2441
            serial = membership.pending_serial
2442
            if serial in serials_to_ack:
2443
                membership.set_sync()
2444
            else:
2445
                membership.reset_sync()
2446

    
2447
        transaction.commit()
2448

    
2449
    qh_ack_serials(list(serials_to_ack))
2450
    return len(memberships)
2451

    
2452
def pre_sync_projects(sync=True):
2453
    ACCEPTED = ProjectMembership.ACCEPTED
2454
    LEAVE_REQUESTED = ProjectMembership.LEAVE_REQUESTED
2455
    PROJECT_DEACTIVATED = ProjectMembership.PROJECT_DEACTIVATED
2456
    psfu = Project.objects.select_for_update()
2457

    
2458
    modified = list(psfu.modified_projects())
2459
    if sync:
2460
        for project in modified:
2461
            objects = project.projectmembership_set.select_for_update()
2462

    
2463
            memberships = objects.actually_accepted()
2464
            for membership in memberships:
2465
                membership.is_pending = True
2466
                membership.save()
2467

    
2468
    reactivating = list(psfu.reactivating_projects())
2469
    if sync:
2470
        for project in reactivating:
2471
            objects = project.projectmembership_set.select_for_update()
2472

    
2473
            memberships = objects.filter(state=PROJECT_DEACTIVATED)
2474
            for membership in memberships:
2475
                membership.is_pending = True
2476
                if membership.leave_request_date is None:
2477
                    membership.state = ACCEPTED
2478
                else:
2479
                    membership.state = LEAVE_REQUESTED
2480
                membership.save()
2481

    
2482
    deactivating = list(psfu.deactivating_projects())
2483
    if sync:
2484
        for project in deactivating:
2485
            objects = project.projectmembership_set.select_for_update()
2486

    
2487
            # Note: we keep a user-level deactivation
2488
            # (e.g. USER_SUSPENDED) intact
2489
            memberships = objects.actually_accepted()
2490
            for membership in memberships:
2491
                membership.is_pending = True
2492
                membership.state = PROJECT_DEACTIVATED
2493
                membership.save()
2494

    
2495
    return (modified, reactivating, deactivating)
2496

    
2497
def do_sync_projects():
2498

    
2499
    ACTUALLY_ACCEPTED = ProjectMembership.ACTUALLY_ACCEPTED
2500
    objects = ProjectMembership.objects.select_for_update()
2501

    
2502
    sub_quota, add_quota = [], []
2503

    
2504
    serial = new_serial()
2505

    
2506
    pending = objects.filter(is_pending=True)
2507
    for membership in pending:
2508

    
2509
        if membership.pending_application:
2510
            m = "%s: impossible: pending_application is not None (%s)" % (
2511
                membership, membership.pending_application)
2512
            raise AssertionError(m)
2513
        if membership.pending_serial:
2514
            m = "%s: impossible: pending_serial is not None (%s)" % (
2515
                membership, membership.pending_serial)
2516
            raise AssertionError(m)
2517

    
2518
        if membership.state in ACTUALLY_ACCEPTED:
2519
            membership.pending_application = membership.project.application
2520

    
2521
        membership.pending_serial = serial
2522
        membership.get_diff_quotas(sub_quota, add_quota)
2523
        membership.save()
2524

    
2525
    transaction.commit()
2526
    # ProjectApplication.approve() unblocks here
2527
    # and can set PENDING an already PENDING membership
2528
    # which has been scheduled to sync with the old project.application
2529
    # Need to check in ProjectMembership.set_sync()
2530

    
2531
    r = qh_add_quota(serial, sub_quota, add_quota)
2532
    if r:
2533
        m = "cannot sync serial: %d" % serial
2534
        raise RuntimeError(m)
2535

    
2536
    return serial
2537

    
2538
def post_sync_projects():
2539
    PROJECT_DEACTIVATED = ProjectMembership.PROJECT_DEACTIVATED
2540
    Q_ACTUALLY_ACCEPTED = ProjectMembership.Q_ACTUALLY_ACCEPTED
2541
    psfu = Project.objects.select_for_update()
2542

    
2543
    modified = psfu.modified_projects()
2544
    for project in modified:
2545
        objects = project.projectmembership_set.select_for_update()
2546

    
2547
        memberships = list(objects.filter(Q_ACTUALLY_ACCEPTED &
2548
                                          Q(is_pending=True)))
2549
        if not memberships:
2550
            project.is_modified = False
2551
            project.save()
2552

    
2553
    reactivating = psfu.reactivating_projects()
2554
    for project in reactivating:
2555
        objects = project.projectmembership_set.select_for_update()
2556
        memberships = list(objects.filter(Q(state=PROJECT_DEACTIVATED) |
2557
                                          Q(is_pending=True)))
2558
        if not memberships:
2559
            project.reactivate()
2560
            project.save()
2561

    
2562
    deactivating = psfu.deactivating_projects()
2563
    for project in deactivating:
2564
        objects = project.projectmembership_set.select_for_update()
2565

    
2566
        memberships = list(objects.filter(Q_ACTUALLY_ACCEPTED |
2567
                                          Q(is_pending=True)))
2568
        if not memberships:
2569
            project.deactivate()
2570
            project.save()
2571

    
2572
    transaction.commit()
2573

    
2574
def sync_projects(sync=True, retries=3, retry_wait=1.0):
2575
    @with_lock(retries, retry_wait)
2576
    def _sync_projects(sync):
2577
        sync_finish_serials()
2578
        # Informative only -- no select_for_update()
2579
        pending = list(ProjectMembership.objects.filter(is_pending=True))
2580

    
2581
        projects_log = pre_sync_projects(sync)
2582
        if sync:
2583
            serial = do_sync_projects()
2584
            sync_finish_serials([serial])
2585
            post_sync_projects()
2586

    
2587
        return (pending, projects_log)
2588
    return _sync_projects(sync)
2589

    
2590
def all_users_quotas(users):
2591
    quotas = {}
2592
    for user in users:
2593
        quotas[user.uuid] = user.all_quotas()
2594
    return quotas
2595

    
2596
def sync_users(users, sync=True, retries=3, retry_wait=1.0):
2597
    @with_lock(retries, retry_wait)
2598
    def _sync_users(users, sync):
2599
        sync_finish_serials()
2600

    
2601
        existing, nonexisting = qh_check_users(users)
2602
        resources = get_resource_names()
2603
        registered_quotas = qh_get_quota_limits(existing, resources)
2604
        astakos_quotas = all_users_quotas(users)
2605

    
2606
        if sync:
2607
            r = register_users(nonexisting)
2608
            r = send_quotas(astakos_quotas)
2609

    
2610
        return (existing, nonexisting, registered_quotas, astakos_quotas)
2611
    return _sync_users(users, sync)
2612

    
2613
def sync_all_users(sync=True, retries=3, retry_wait=1.0):
2614
    users = AstakosUser.objects.verified()
2615
    return sync_users(users, sync, retries, retry_wait)
2616

    
2617
class ProjectMembershipHistory(models.Model):
2618
    reasons_list    =   ['ACCEPT', 'REJECT', 'REMOVE']
2619
    reasons         =   dict((k, v) for v, k in enumerate(reasons_list))
2620

    
2621
    person  =   models.BigIntegerField()
2622
    project =   models.BigIntegerField()
2623
    date    =   models.DateField(auto_now_add=True)
2624
    reason  =   models.IntegerField()
2625
    serial  =   models.BigIntegerField()
2626

    
2627
### SIGNALS ###
2628
################
2629

    
2630
def create_astakos_user(u):
2631
    try:
2632
        AstakosUser.objects.get(user_ptr=u.pk)
2633
    except AstakosUser.DoesNotExist:
2634
        extended_user = AstakosUser(user_ptr_id=u.pk)
2635
        extended_user.__dict__.update(u.__dict__)
2636
        extended_user.save()
2637
        if not extended_user.has_auth_provider('local'):
2638
            extended_user.add_auth_provider('local')
2639
    except BaseException, e:
2640
        logger.exception(e)
2641

    
2642
def fix_superusers():
2643
    # Associate superusers with AstakosUser
2644
    admins = User.objects.filter(is_superuser=True)
2645
    for u in admins:
2646
        create_astakos_user(u)
2647

    
2648
def user_post_save(sender, instance, created, **kwargs):
2649
    if not created:
2650
        return
2651
    create_astakos_user(instance)
2652
post_save.connect(user_post_save, sender=User)
2653

    
2654
def astakosuser_post_save(sender, instance, created, **kwargs):
2655
    pass
2656

    
2657
post_save.connect(astakosuser_post_save, sender=AstakosUser)
2658

    
2659
def resource_post_save(sender, instance, created, **kwargs):
2660
    pass
2661

    
2662
post_save.connect(resource_post_save, sender=Resource)
2663

    
2664
def renew_token(sender, instance, **kwargs):
2665
    if not instance.auth_token:
2666
        instance.renew_token()
2667
pre_save.connect(renew_token, sender=AstakosUser)
2668
pre_save.connect(renew_token, sender=Service)