Statistics
| Branch: | Tag: | Revision:

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

History | View | Annotate | Download (89.8 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 verified(self):
316
        return self.filter(email_verified=True)
317

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

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

    
340

    
341

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

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

    
358

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

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

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

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

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

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

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

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

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

    
394
    objects = AstakosUserManager()
395

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

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

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

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

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

    
431
    def is_project_admin(self, application_id=None):
432
        return self.uuid in PROJECT_ADMINS
433

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

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

    
448
    def all_quotas(self):
449
        quotas = self.initial_quotas()
450

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

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

    
468
    @property
469
    def policies(self):
470
        return self.astakosuserquota_set.select_related().all()
471

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

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

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

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

    
516
    def save(self, update_timestamps=True, **kwargs):
517
        if update_timestamps:
518
            if not self.id:
519
                self.date_joined = datetime.now()
520
            self.updated = datetime.now()
521

    
522
        # update date_signed_terms if necessary
523
        if self.__has_signed_terms != self.has_signed_terms:
524
            self.date_signed_terms = datetime.now()
525

    
526
        self.update_uuid()
527

    
528
        if self.username != self.email.lower():
529
            # set username
530
            self.username = self.email.lower()
531

    
532
        super(AstakosUser, self).save(**kwargs)
533

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

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

    
550
    def flush_sessions(self, current_key=None):
551
        q = self.sessions
552
        if current_key:
553
            q = q.exclude(session_key=current_key)
554

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

    
564
    def __unicode__(self):
565
        return '%s (%s)' % (self.realname, self.email)
566

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

    
574
    def email_change_is_pending(self):
575
        return self.emailchanges.count() > 0
576

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

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

    
601
    def can_login_with_auth_provider(self, provider):
602
        if not self.has_auth_provider(provider):
603
            return False
604
        else:
605
            return auth_providers.get_provider(provider).is_available_for_login()
606

    
607
    def can_add_auth_provider(self, provider, include_unverified=False, **kwargs):
608
        provider_settings = auth_providers.get_provider(provider)
609

    
610
        if not provider_settings.is_available_for_add():
611
            return False
612

    
613
        if self.has_auth_provider(provider) and \
614
           provider_settings.one_per_user:
615
            return False
616

    
617
        if 'provider_info' in kwargs:
618
            kwargs.pop('provider_info')
619

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

    
632
        return True
633

    
634
    def can_remove_auth_provider(self, module):
635
        provider = auth_providers.get_provider(module)
636
        existing = self.get_active_auth_providers()
637
        existing_for_provider = self.get_active_auth_providers(module=module)
638

    
639
        if len(existing) <= 1:
640
            return False
641

    
642
        if len(existing_for_provider) == 1 and provider.is_required():
643
            return False
644

    
645
        return provider.is_available_for_remove()
646

    
647
    def can_change_password(self):
648
        return self.has_auth_provider('local', auth_backend='astakos')
649

    
650
    def can_change_email(self):
651
        non_astakos_local = self.get_auth_providers().filter(module='local')
652
        non_astakos_local = non_astakos_local.exclude(auth_backend='astakos')
653
        return non_astakos_local.count() == 0
654

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

    
662
    def has_auth_provider(self, provider, **kwargs):
663
        return bool(self.get_auth_providers().filter(module=provider,
664
                                               **kwargs).count())
665

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

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

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

    
693
        provider = self.add_auth_provider(pending.provider,
694
                               identifier=pending.third_party_identifier,
695
                                affiliation=pending.affiliation,
696
                                          provider_info=pending.info)
697

    
698
        if email_re.match(pending.email or '') and pending.email != self.email:
699
            self.additionalmail_set.get_or_create(email=pending.email)
700

    
701
        pending.delete()
702
        return provider
703

    
704
    def remove_auth_provider(self, provider, **kwargs):
705
        self.get_auth_providers().get(module=provider, **kwargs).delete()
706

    
707
    # user urls
708
    def get_resend_activation_url(self):
709
        return reverse('send_activation', kwargs={'user_id': self.pk})
710

    
711
    def get_provider_remove_url(self, module, **kwargs):
712
        return reverse('remove_auth_provider', kwargs={
713
            'pk': self.get_auth_providers().get(module=module, **kwargs).pk})
714

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

    
722
    def get_password_reset_url(self, token_generator=default_token_generator):
723
        return reverse('django.contrib.auth.views.password_reset_confirm',
724
                          kwargs={'uidb36':int_to_base36(self.id),
725
                                  'token':token_generator.make_token(self)})
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
class SessionCatalog(models.Model):
1181
    session_key = models.CharField(_('session key'), max_length=40)
1182
    user = models.ForeignKey(AstakosUser, related_name='sessions', null=True)
1183

    
1184

    
1185
### PROJECTS ###
1186
################
1187

    
1188
def synced_model_metaclass(class_name, class_parents, class_attributes):
1189

    
1190
    new_attributes = {}
1191
    sync_attributes = {}
1192

    
1193
    for name, value in class_attributes.iteritems():
1194
        sync, underscore, rest = name.partition('_')
1195
        if sync == 'sync' and underscore == '_':
1196
            sync_attributes[rest] = value
1197
        else:
1198
            new_attributes[name] = value
1199

    
1200
    if 'prefix' not in sync_attributes:
1201
        m = ("you did not specify a 'sync_prefix' attribute "
1202
             "in class '%s'" % (class_name,))
1203
        raise ValueError(m)
1204

    
1205
    prefix = sync_attributes.pop('prefix')
1206
    class_name = sync_attributes.pop('classname', prefix + '_model')
1207

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

    
1216
        new_attributes[newname] = value
1217

    
1218
    newclass = type(class_name, class_parents, new_attributes)
1219
    return newclass
1220

    
1221

    
1222
def make_synced(prefix='sync', name='SyncedState'):
1223

    
1224
    the_name = name
1225
    the_prefix = prefix
1226

    
1227
    class SyncedState(models.Model):
1228

    
1229
        sync_classname      = the_name
1230
        sync_prefix         = the_prefix
1231
        __metaclass__       = synced_model_metaclass
1232

    
1233
        sync_new_state      = models.BigIntegerField(null=True)
1234
        sync_synced_state   = models.BigIntegerField(null=True)
1235
        STATUS_SYNCED       = 0
1236
        STATUS_PENDING      = 1
1237
        sync_status         = models.IntegerField(db_index=True)
1238

    
1239
        class Meta:
1240
            abstract = True
1241

    
1242
        class NotSynced(Exception):
1243
            pass
1244

    
1245
        def sync_init_state(self, state):
1246
            self.sync_synced_state = state
1247
            self.sync_new_state = state
1248
            self.sync_status = self.STATUS_SYNCED
1249

    
1250
        def sync_get_status(self):
1251
            return self.sync_status
1252

    
1253
        def sync_set_status(self):
1254
            if self.sync_new_state != self.sync_synced_state:
1255
                self.sync_status = self.STATUS_PENDING
1256
            else:
1257
                self.sync_status = self.STATUS_SYNCED
1258

    
1259
        def sync_set_synced(self):
1260
            self.sync_synced_state = self.sync_new_state
1261
            self.sync_status = self.STATUS_SYNCED
1262

    
1263
        def sync_get_synced_state(self):
1264
            return self.sync_synced_state
1265

    
1266
        def sync_set_new_state(self, new_state):
1267
            self.sync_new_state = new_state
1268
            self.sync_set_status()
1269

    
1270
        def sync_get_new_state(self):
1271
            return self.sync_new_state
1272

    
1273
        def sync_set_synced_state(self, synced_state):
1274
            self.sync_synced_state = synced_state
1275
            self.sync_set_status()
1276

    
1277
        def sync_get_pending_objects(self):
1278
            kw = dict((the_prefix + '_status', self.STATUS_PENDING))
1279
            return self.objects.filter(**kw)
1280

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

    
1285
        def sync_verify_get_synced_state(self):
1286
            status = self.sync_get_status()
1287
            state = self.sync_get_synced_state()
1288
            verified = (status == self.STATUS_SYNCED)
1289
            return state, verified
1290

    
1291
        def sync_is_synced(self):
1292
            state, verified = self.sync_verify_get_synced_state()
1293
            return verified
1294

    
1295
    return SyncedState
1296

    
1297
SyncedState = make_synced(prefix='sync', name='SyncedState')
1298

    
1299

    
1300
class ChainManager(ForUpdateManager):
1301

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

    
1310
    def all_full_state(self):
1311
        d = {}
1312
        chains = self.all()
1313
        for chain in chains:
1314
            d[chain.pk] = chain.full_state()
1315
        return d
1316

    
1317
    def of_project(self, project):
1318
        if project is None:
1319
            return None
1320
        try:
1321
            return self.get(chain=project.id)
1322
        except Chain.DoesNotExist:
1323
            raise AssertionError('project with no chain')
1324

    
1325

    
1326
class Chain(models.Model):
1327
    chain  =   models.AutoField(primary_key=True)
1328

    
1329
    def __str__(self):
1330
        return "%s" % (self.chain,)
1331

    
1332
    objects = ChainManager()
1333

    
1334
    PENDING            = 0
1335
    DENIED             = 3
1336
    DISMISSED          = 4
1337
    CANCELLED          = 5
1338

    
1339
    APPROVED           = 10
1340
    APPROVED_PENDING   = 11
1341
    SUSPENDED          = 12
1342
    SUSPENDED_PENDING  = 13
1343
    TERMINATED         = 14
1344
    TERMINATED_PENDING = 15
1345

    
1346
    PENDING_STATES = [PENDING,
1347
                      APPROVED_PENDING,
1348
                      SUSPENDED_PENDING,
1349
                      TERMINATED_PENDING,
1350
                      ]
1351

    
1352
    SKIP_STATES = [DISMISSED,
1353
                   CANCELLED,
1354
                   TERMINATED]
1355

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

    
1369

    
1370
    @classmethod
1371
    def _chain_state(cls, project_state, app_state):
1372
        s = CHAIN_STATE.get((project_state, app_state), None)
1373
        if s is None:
1374
            raise AssertionError('inconsistent chain state')
1375
        return s
1376

    
1377
    @classmethod
1378
    def chain_state(cls, project, app):
1379
        p_state = project.state if project else None
1380
        return cls._chain_state(p_state, app.state)
1381

    
1382
    @classmethod
1383
    def state_display(cls, s):
1384
        if s is None:
1385
            return _("Unknown")
1386
        return cls.STATE_DISPLAY.get(s, _("Inconsistent"))
1387

    
1388
    def last_application(self):
1389
        return self.chained_apps.order_by('-id')[0]
1390

    
1391
    def get_project(self):
1392
        try:
1393
            return self.chained_project
1394
        except Project.DoesNotExist:
1395
            return None
1396

    
1397
    def get_elements(self):
1398
        project = self.get_project()
1399
        app = self.last_application()
1400
        return project, app
1401

    
1402
    def full_state(self):
1403
        project, app = self.get_elements()
1404
        s = self.chain_state(project, app)
1405
        return s, project, app
1406

    
1407
def new_chain():
1408
    c = Chain.objects.create()
1409
    return c
1410

    
1411

    
1412
class ProjectApplicationManager(ForUpdateManager):
1413

    
1414
    def user_visible_projects(self, *filters, **kw_filters):
1415
        model = self.model
1416
        return self.filter(model.Q_PENDING | model.Q_APPROVED)
1417

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

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

    
1436
        return self.user_visible_by_chain(participates_filters).order_by('issue_date').distinct()
1437

    
1438
    def search_by_name(self, *search_strings):
1439
        q = Q()
1440
        for s in search_strings:
1441
            q = q | Q(name__icontains=s)
1442
        return self.filter(q)
1443

    
1444
    def latest_of_chain(self, chain_id):
1445
        try:
1446
            return self.filter(chain=chain_id).order_by('-id')[0]
1447
        except IndexError:
1448
            return None
1449

    
1450

    
1451
class ProjectApplication(models.Model):
1452
    applicant               =   models.ForeignKey(
1453
                                    AstakosUser,
1454
                                    related_name='projects_applied',
1455
                                    db_index=True)
1456

    
1457
    PENDING     =    0
1458
    APPROVED    =    1
1459
    REPLACED    =    2
1460
    DENIED      =    3
1461
    DISMISSED   =    4
1462
    CANCELLED   =    5
1463

    
1464
    state                   =   models.IntegerField(default=PENDING,
1465
                                                    db_index=True)
1466

    
1467
    owner                   =   models.ForeignKey(
1468
                                    AstakosUser,
1469
                                    related_name='projects_owned',
1470
                                    db_index=True)
1471

    
1472
    chain                   =   models.ForeignKey(Chain,
1473
                                                  related_name='chained_apps',
1474
                                                  db_column='chain')
1475
    precursor_application   =   models.ForeignKey('ProjectApplication',
1476
                                                  null=True,
1477
                                                  blank=True)
1478

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

    
1497
    objects                 =   ProjectApplicationManager()
1498

    
1499
    # Compiled queries
1500
    Q_PENDING  = Q(state=PENDING)
1501
    Q_APPROVED = Q(state=APPROVED)
1502
    Q_DENIED   = Q(state=DENIED)
1503

    
1504
    class Meta:
1505
        unique_together = ("chain", "id")
1506

    
1507
    def __unicode__(self):
1508
        return "%s applied by %s" % (self.name, self.applicant)
1509

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

    
1520
    def get_project(self):
1521
        try:
1522
            project = Project.objects.get(id=self.chain, state=Project.APPROVED)
1523
            return Project
1524
        except Project.DoesNotExist, e:
1525
            return None
1526

    
1527
    def state_display(self):
1528
        return self.APPLICATION_STATE_DISPLAY.get(self.state, _('Unknown'))
1529

    
1530
    def project_state_display(self):
1531
        try:
1532
            project = self.project
1533
            return project.state_display()
1534
        except Project.DoesNotExist:
1535
            return self.state_display()
1536

    
1537
    def add_resource_policy(self, service, resource, uplimit):
1538
        """Raises ObjectDoesNotExist, IntegrityError"""
1539
        q = self.projectresourcegrant_set
1540
        resource = Resource.objects.get(service__name=service, name=resource)
1541
        q.create(resource=resource, member_capacity=uplimit)
1542

    
1543
    def members_count(self):
1544
        return self.project.approved_memberships.count()
1545

    
1546
    @property
1547
    def grants(self):
1548
        return self.projectresourcegrant_set.values(
1549
            'member_capacity', 'resource__name', 'resource__service__name')
1550

    
1551
    @property
1552
    def resource_policies(self):
1553
        return [str(rp) for rp in self.projectresourcegrant_set.all()]
1554

    
1555
    @resource_policies.setter
1556
    def resource_policies(self, policies):
1557
        for p in policies:
1558
            service = p.get('service', None)
1559
            resource = p.get('resource', None)
1560
            uplimit = p.get('uplimit', 0)
1561
            self.add_resource_policy(service, resource, uplimit)
1562

    
1563
    def pending_modifications_incl_me(self):
1564
        q = self.chained_applications()
1565
        q = q.filter(Q(state=self.PENDING))
1566
        return q
1567

    
1568
    def last_pending_incl_me(self):
1569
        try:
1570
            return self.pending_modifications_incl_me().order_by('-id')[0]
1571
        except IndexError:
1572
            return None
1573

    
1574
    def pending_modifications(self):
1575
        return self.pending_modifications_incl_me().filter(~Q(id=self.id))
1576

    
1577
    def last_pending(self):
1578
        try:
1579
            return self.pending_modifications().order_by('-id')[0]
1580
        except IndexError:
1581
            return None
1582

    
1583
    def is_modification(self):
1584
        # if self.state != self.PENDING:
1585
        #     return False
1586
        parents = self.chained_applications().filter(id__lt=self.id)
1587
        parents = parents.filter(state__in=[self.APPROVED])
1588
        return parents.count() > 0
1589

    
1590
    def chained_applications(self):
1591
        return ProjectApplication.objects.filter(chain=self.chain)
1592

    
1593
    def is_latest(self):
1594
        return self.chained_applications().order_by('-id')[0] == self
1595

    
1596
    def has_pending_modifications(self):
1597
        return bool(self.last_pending())
1598

    
1599
    def denied_modifications(self):
1600
        q = self.chained_applications()
1601
        q = q.filter(Q(state=self.DENIED))
1602
        q = q.filter(~Q(id=self.id))
1603
        return q
1604

    
1605
    def last_denied(self):
1606
        try:
1607
            return self.denied_modifications().order_by('-id')[0]
1608
        except IndexError:
1609
            return None
1610

    
1611
    def has_denied_modifications(self):
1612
        return bool(self.last_denied())
1613

    
1614
    def is_applied(self):
1615
        try:
1616
            self.project
1617
            return True
1618
        except Project.DoesNotExist:
1619
            return False
1620

    
1621
    def get_project(self):
1622
        try:
1623
            return Project.objects.get(id=self.chain)
1624
        except Project.DoesNotExist:
1625
            return None
1626

    
1627
    def project_exists(self):
1628
        return self.get_project() is not None
1629

    
1630
    def _get_project_for_update(self):
1631
        try:
1632
            objects = Project.objects.select_for_update()
1633
            project = objects.get(id=self.chain)
1634
            return project
1635
        except Project.DoesNotExist:
1636
            return None
1637

    
1638
    def can_cancel(self):
1639
        return self.state == self.PENDING
1640

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

    
1647
        self.state = self.CANCELLED
1648
        self.save()
1649

    
1650
    def can_dismiss(self):
1651
        return self.state == self.DENIED
1652

    
1653
    def dismiss(self):
1654
        if not self.can_dismiss():
1655
            m = _("cannot dismiss: application '%s' in state '%s'") % (
1656
                    self.id, self.state)
1657
            raise AssertionError(m)
1658

    
1659
        self.state = self.DISMISSED
1660
        self.save()
1661

    
1662
    def can_deny(self):
1663
        return self.state == self.PENDING
1664

    
1665
    def deny(self):
1666
        if not self.can_deny():
1667
            m = _("cannot deny: application '%s' in state '%s'") % (
1668
                    self.id, self.state)
1669
            raise AssertionError(m)
1670

    
1671
        self.state = self.DENIED
1672
        self.response_date = datetime.now()
1673
        self.save()
1674

    
1675
    def can_approve(self):
1676
        return self.state == self.PENDING
1677

    
1678
    def approve(self, approval_user=None):
1679
        """
1680
        If approval_user then during owner membership acceptance
1681
        it is checked whether the request_user is eligible.
1682

1683
        Raises:
1684
            PermissionDenied
1685
        """
1686

    
1687
        if not transaction.is_managed():
1688
            raise AssertionError("NOPE")
1689

    
1690
        new_project_name = self.name
1691
        if not self.can_approve():
1692
            m = _("cannot approve: project '%s' in state '%s'") % (
1693
                    new_project_name, self.state)
1694
            raise AssertionError(m) # invalid argument
1695

    
1696
        now = datetime.now()
1697
        project = self._get_project_for_update()
1698

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

    
1710
        new_project = False
1711
        if project is None:
1712
            new_project = True
1713
            project = Project(id=self.chain)
1714

    
1715
        project.name = new_project_name
1716
        project.application = self
1717
        project.last_approval_date = now
1718
        if not new_project:
1719
            project.is_modified = True
1720

    
1721
        project.save()
1722

    
1723
        self.state = self.APPROVED
1724
        self.response_date = now
1725
        self.save()
1726

    
1727
    @property
1728
    def member_join_policy_display(self):
1729
        return PROJECT_MEMBER_JOIN_POLICIES.get(str(self.member_join_policy))
1730

    
1731
    @property
1732
    def member_leave_policy_display(self):
1733
        return PROJECT_MEMBER_LEAVE_POLICIES.get(str(self.member_leave_policy))
1734

    
1735
class ProjectResourceGrant(models.Model):
1736

    
1737
    resource                =   models.ForeignKey(Resource)
1738
    project_application     =   models.ForeignKey(ProjectApplication,
1739
                                                  null=True)
1740
    project_capacity        =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1741
    project_import_limit    =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1742
    project_export_limit    =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1743
    member_capacity         =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1744
    member_import_limit     =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1745
    member_export_limit     =   intDecimalField(default=QH_PRACTICALLY_INFINITE)
1746

    
1747
    objects = ExtendedManager()
1748

    
1749
    class Meta:
1750
        unique_together = ("resource", "project_application")
1751

    
1752
    def member_quota_values(self):
1753
        return QuotaValues(
1754
            quantity = 0,
1755
            capacity = self.member_capacity,
1756
            import_limit = self.member_import_limit,
1757
            export_limit = self.member_export_limit)
1758

    
1759
    def display_member_capacity(self):
1760
        if self.member_capacity:
1761
            if self.resource.unit:
1762
                return ProjectResourceGrant.display_filesize(
1763
                    self.member_capacity)
1764
            else:
1765
                if math.isinf(self.member_capacity):
1766
                    return 'Unlimited'
1767
                else:
1768
                    return self.member_capacity
1769
        else:
1770
            return 'Unlimited'
1771

    
1772
    def __str__(self):
1773
        return 'Max %s per user: %s' % (self.resource.pluralized_display_name,
1774
                                        self.display_member_capacity())
1775

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

    
1800

    
1801
class ProjectManager(ForUpdateManager):
1802

    
1803
    def terminated_projects(self):
1804
        q = self.model.Q_TERMINATED
1805
        return self.filter(q)
1806

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

    
1811
    def terminating_projects(self):
1812
        q = self.model.Q_TERMINATED & Q(is_active=True)
1813
        return self.filter(q)
1814

    
1815
    def deactivated_projects(self):
1816
        q = self.model.Q_DEACTIVATED
1817
        return self.filter(q)
1818

    
1819
    def deactivating_projects(self):
1820
        q = self.model.Q_DEACTIVATED & Q(is_active=True)
1821
        return self.filter(q)
1822

    
1823
    def modified_projects(self):
1824
        return self.filter(is_modified=True)
1825

    
1826
    def reactivating_projects(self):
1827
        return self.filter(state=Project.APPROVED, is_active=False)
1828

    
1829
    def expired_projects(self):
1830
        q = (~Q(state=Project.TERMINATED) &
1831
              Q(application__end_date__lt=datetime.now()))
1832
        return self.filter(q)
1833

    
1834
    def search_by_name(self, *search_strings):
1835
        q = Q()
1836
        for s in search_strings:
1837
            q = q | Q(name__icontains=s)
1838
        return self.filter(q)
1839

    
1840

    
1841
class Project(models.Model):
1842

    
1843
    id                          =   models.OneToOneField(Chain,
1844
                                                      related_name='chained_project',
1845
                                                      db_column='id',
1846
                                                      primary_key=True)
1847

    
1848
    application                 =   models.OneToOneField(
1849
                                            ProjectApplication,
1850
                                            related_name='project')
1851
    last_approval_date          =   models.DateTimeField(null=True)
1852

    
1853
    members                     =   models.ManyToManyField(
1854
                                            AstakosUser,
1855
                                            through='ProjectMembership')
1856

    
1857
    deactivation_reason         =   models.CharField(max_length=255, null=True)
1858
    deactivation_date           =   models.DateTimeField(null=True)
1859

    
1860
    creation_date               =   models.DateTimeField(auto_now_add=True)
1861
    name                        =   models.CharField(
1862
                                            max_length=80,
1863
                                            null=True,
1864
                                            db_index=True,
1865
                                            unique=True)
1866

    
1867
    APPROVED    = 1
1868
    SUSPENDED   = 10
1869
    TERMINATED  = 100
1870

    
1871
    is_modified                 =   models.BooleanField(default=False,
1872
                                                        db_index=True)
1873
    is_active                   =   models.BooleanField(default=True,
1874
                                                        db_index=True)
1875
    state                       =   models.IntegerField(default=APPROVED,
1876
                                                        db_index=True)
1877

    
1878
    objects     =   ProjectManager()
1879

    
1880
    # Compiled queries
1881
    Q_TERMINATED  = Q(state=TERMINATED)
1882
    Q_SUSPENDED   = Q(state=SUSPENDED)
1883
    Q_DEACTIVATED = Q_TERMINATED | Q_SUSPENDED
1884

    
1885
    def __str__(self):
1886
        return _("<project %s '%s'>") % (self.id, self.application.name)
1887

    
1888
    __repr__ = __str__
1889

    
1890
    STATE_DISPLAY = {
1891
        APPROVED   : 'Active',
1892
        SUSPENDED  : 'Suspended',
1893
        TERMINATED : 'Terminated'
1894
        }
1895

    
1896
    def state_display(self):
1897
        return self.STATE_DISPLAY.get(self.state, _('Unknown'))
1898

    
1899
    def admin_state_display(self):
1900
        s = self.state_display()
1901
        if self.sync_pending():
1902
            s += ' (sync pending)'
1903
        return s
1904

    
1905
    def sync_pending(self):
1906
        if self.state != self.APPROVED:
1907
            return self.is_active
1908
        return not self.is_active or self.is_modified
1909

    
1910
    def expiration_info(self):
1911
        return (str(self.id), self.name, self.state_display(),
1912
                str(self.application.end_date))
1913

    
1914
    def is_deactivated(self, reason=None):
1915
        if reason is not None:
1916
            return self.state == reason
1917

    
1918
        return self.state != self.APPROVED
1919

    
1920
    def is_deactivating(self, reason=None):
1921
        if not self.is_active:
1922
            return False
1923

    
1924
        return self.is_deactivated(reason)
1925

    
1926
    def is_deactivated_strict(self, reason=None):
1927
        if self.is_active:
1928
            return False
1929

    
1930
        return self.is_deactivated(reason)
1931

    
1932
    ### Deactivation calls
1933

    
1934
    def deactivate(self):
1935
        self.deactivation_date = datetime.now()
1936
        self.is_active = False
1937

    
1938
    def reactivate(self):
1939
        self.deactivation_date = None
1940
        self.is_active = True
1941

    
1942
    def terminate(self):
1943
        self.deactivation_reason = 'TERMINATED'
1944
        self.state = self.TERMINATED
1945
        self.name = None
1946
        self.save()
1947

    
1948
    def suspend(self):
1949
        self.deactivation_reason = 'SUSPENDED'
1950
        self.state = self.SUSPENDED
1951
        self.save()
1952

    
1953
    def resume(self):
1954
        self.deactivation_reason = None
1955
        self.state = self.APPROVED
1956
        self.save()
1957

    
1958
    ### Logical checks
1959

    
1960
    def is_inconsistent(self):
1961
        now = datetime.now()
1962
        dates = [self.creation_date,
1963
                 self.last_approval_date,
1964
                 self.deactivation_date]
1965
        return any([date > now for date in dates])
1966

    
1967
    def is_active_strict(self):
1968
        return self.is_active and self.state == self.APPROVED
1969

    
1970
    def is_approved(self):
1971
        return self.state == self.APPROVED
1972

    
1973
    @property
1974
    def is_alive(self):
1975
        return not self.is_terminated
1976

    
1977
    @property
1978
    def is_terminated(self):
1979
        return self.is_deactivated(self.TERMINATED)
1980

    
1981
    @property
1982
    def is_suspended(self):
1983
        return self.is_deactivated(self.SUSPENDED)
1984

    
1985
    def violates_resource_grants(self):
1986
        return False
1987

    
1988
    def violates_members_limit(self, adding=0):
1989
        application = self.application
1990
        limit = application.limit_on_members_number
1991
        if limit is None:
1992
            return False
1993
        return (len(self.approved_members) + adding > limit)
1994

    
1995

    
1996
    ### Other
1997

    
1998
    def count_pending_memberships(self):
1999
        memb_set = self.projectmembership_set
2000
        memb_count = memb_set.filter(state=ProjectMembership.REQUESTED).count()
2001
        return memb_count
2002

    
2003
    def members_count(self):
2004
        return self.approved_memberships.count()
2005

    
2006
    @property
2007
    def approved_memberships(self):
2008
        query = ProjectMembership.Q_ACCEPTED_STATES
2009
        return self.projectmembership_set.filter(query)
2010

    
2011
    @property
2012
    def approved_members(self):
2013
        return [m.person for m in self.approved_memberships]
2014

    
2015
    def add_member(self, user):
2016
        """
2017
        Raises:
2018
            django.exceptions.PermissionDenied
2019
            astakos.im.models.AstakosUser.DoesNotExist
2020
        """
2021
        if isinstance(user, int):
2022
            user = AstakosUser.objects.get(user=user)
2023

    
2024
        m, created = ProjectMembership.objects.get_or_create(
2025
            person=user, project=self
2026
        )
2027
        m.accept()
2028

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

    
2039
        m = ProjectMembership.objects.get(person=user, project=self)
2040
        m.remove()
2041

    
2042

    
2043
CHAIN_STATE = {
2044
    (Project.APPROVED,   ProjectApplication.PENDING)  : Chain.APPROVED_PENDING,
2045
    (Project.APPROVED,   ProjectApplication.APPROVED) : Chain.APPROVED,
2046
    (Project.APPROVED,   ProjectApplication.DENIED)   : Chain.APPROVED,
2047
    (Project.APPROVED,   ProjectApplication.DISMISSED): Chain.APPROVED,
2048
    (Project.APPROVED,   ProjectApplication.CANCELLED): Chain.APPROVED,
2049

    
2050
    (Project.SUSPENDED,  ProjectApplication.PENDING)  : Chain.SUSPENDED_PENDING,
2051
    (Project.SUSPENDED,  ProjectApplication.APPROVED) : Chain.SUSPENDED,
2052
    (Project.SUSPENDED,  ProjectApplication.DENIED)   : Chain.SUSPENDED,
2053
    (Project.SUSPENDED,  ProjectApplication.DISMISSED): Chain.SUSPENDED,
2054
    (Project.SUSPENDED,  ProjectApplication.CANCELLED): Chain.SUSPENDED,
2055

    
2056
    (Project.TERMINATED, ProjectApplication.PENDING)  : Chain.TERMINATED_PENDING,
2057
    (Project.TERMINATED, ProjectApplication.APPROVED) : Chain.TERMINATED,
2058
    (Project.TERMINATED, ProjectApplication.DENIED)   : Chain.TERMINATED,
2059
    (Project.TERMINATED, ProjectApplication.DISMISSED): Chain.TERMINATED,
2060
    (Project.TERMINATED, ProjectApplication.CANCELLED): Chain.TERMINATED,
2061

    
2062
    (None,               ProjectApplication.PENDING)  : Chain.PENDING,
2063
    (None,               ProjectApplication.DENIED)   : Chain.DENIED,
2064
    (None,               ProjectApplication.DISMISSED): Chain.DISMISSED,
2065
    (None,               ProjectApplication.CANCELLED): Chain.CANCELLED,
2066
    }
2067

    
2068

    
2069
class PendingMembershipError(Exception):
2070
    pass
2071

    
2072

    
2073
class ProjectMembershipManager(ForUpdateManager):
2074

    
2075
    def any_accepted(self):
2076
        q = (Q(state=ProjectMembership.ACCEPTED) |
2077
             Q(state=ProjectMembership.PROJECT_DEACTIVATED))
2078
        return self.filter(q)
2079

    
2080
    def actually_accepted(self):
2081
        q = self.model.Q_ACTUALLY_ACCEPTED
2082
        return self.filter(q)
2083

    
2084
    def requested(self):
2085
        return self.filter(state=ProjectMembership.REQUESTED)
2086

    
2087
    def suspended(self):
2088
        return self.filter(state=ProjectMembership.USER_SUSPENDED)
2089

    
2090
class ProjectMembership(models.Model):
2091

    
2092
    person              =   models.ForeignKey(AstakosUser)
2093
    request_date        =   models.DateField(auto_now_add=True)
2094
    project             =   models.ForeignKey(Project)
2095

    
2096
    REQUESTED           =   0
2097
    ACCEPTED            =   1
2098
    LEAVE_REQUESTED     =   5
2099
    # User deactivation
2100
    USER_SUSPENDED      =   10
2101
    # Project deactivation
2102
    PROJECT_DEACTIVATED =   100
2103

    
2104
    REMOVED             =   200
2105

    
2106
    ASSOCIATED_STATES   =   set([REQUESTED,
2107
                                 ACCEPTED,
2108
                                 LEAVE_REQUESTED,
2109
                                 USER_SUSPENDED,
2110
                                 PROJECT_DEACTIVATED])
2111

    
2112
    ACCEPTED_STATES     =   set([ACCEPTED,
2113
                                 LEAVE_REQUESTED,
2114
                                 USER_SUSPENDED,
2115
                                 PROJECT_DEACTIVATED])
2116

    
2117
    ACTUALLY_ACCEPTED   =   set([ACCEPTED, LEAVE_REQUESTED])
2118

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

    
2133
    acceptance_date     =   models.DateField(null=True, db_index=True)
2134
    leave_request_date  =   models.DateField(null=True)
2135

    
2136
    objects     =   ProjectMembershipManager()
2137

    
2138
    # Compiled queries
2139
    Q_ACCEPTED_STATES = ~Q(state=REQUESTED) & ~Q(state=REMOVED)
2140
    Q_ACTUALLY_ACCEPTED = Q(state=ACCEPTED) | Q(state=LEAVE_REQUESTED)
2141

    
2142
    MEMBERSHIP_STATE_DISPLAY = {
2143
        REQUESTED           : _('Requested'),
2144
        ACCEPTED            : _('Accepted'),
2145
        LEAVE_REQUESTED     : _('Leave Requested'),
2146
        USER_SUSPENDED      : _('Suspended'),
2147
        PROJECT_DEACTIVATED : _('Accepted'), # sic
2148
        REMOVED             : _('Pending removal'),
2149
        }
2150

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

    
2160
    def state_display(self):
2161
        return self.MEMBERSHIP_STATE_DISPLAY.get(self.state, _('Unknown'))
2162

    
2163
    def user_friendly_state_display(self):
2164
        return self.USER_FRIENDLY_STATE_DISPLAY.get(self.state, _('Unknown'))
2165

    
2166
    def get_combined_state(self):
2167
        return self.state, self.is_active, self.is_pending
2168

    
2169
    class Meta:
2170
        unique_together = ("person", "project")
2171
        #index_together = [["project", "state"]]
2172

    
2173
    def __str__(self):
2174
        return _("<'%s' membership in '%s'>") % (
2175
                self.person.username, self.project)
2176

    
2177
    __repr__ = __str__
2178

    
2179
    def __init__(self, *args, **kwargs):
2180
        self.state = self.REQUESTED
2181
        super(ProjectMembership, self).__init__(*args, **kwargs)
2182

    
2183
    def _set_history_item(self, reason, date=None):
2184
        if isinstance(reason, basestring):
2185
            reason = ProjectMembershipHistory.reasons.get(reason, -1)
2186

    
2187
        history_item = ProjectMembershipHistory(
2188
                            serial=self.id,
2189
                            person=self.person_id,
2190
                            project=self.project_id,
2191
                            date=date or datetime.now(),
2192
                            reason=reason)
2193
        history_item.save()
2194
        serial = history_item.id
2195

    
2196
    def can_accept(self):
2197
        return self.state == self.REQUESTED
2198

    
2199
    def accept(self):
2200
        if self.is_pending:
2201
            m = _("%s: attempt to accept while is pending") % (self,)
2202
            raise AssertionError(m)
2203

    
2204
        if not self.can_accept():
2205
            m = _("%s: attempt to accept in state '%s'") % (self, self.state)
2206
            raise AssertionError(m)
2207

    
2208
        now = datetime.now()
2209
        self.acceptance_date = now
2210
        self._set_history_item(reason='ACCEPT', date=now)
2211
        if self.project.is_approved():
2212
            self.state = self.ACCEPTED
2213
            self.is_pending = True
2214
        else:
2215
            self.state = self.PROJECT_DEACTIVATED
2216

    
2217
        self.save()
2218

    
2219
    def can_leave(self):
2220
        return self.state in self.ACCEPTED_STATES
2221

    
2222
    def leave_request(self):
2223
        if self.is_pending:
2224
            m = _("%s: attempt to request to leave while is pending") % (self,)
2225
            raise AssertionError(m)
2226

    
2227
        if not self.can_leave():
2228
            m = _("%s: attempt to request to leave in state '%s'") % (
2229
                self, self.state)
2230
            raise AssertionError(m)
2231

    
2232
        self.leave_request_date = datetime.now()
2233
        self.state = self.LEAVE_REQUESTED
2234
        self.save()
2235

    
2236
    def can_deny_leave(self):
2237
        return self.state == self.LEAVE_REQUESTED
2238

    
2239
    def leave_request_deny(self):
2240
        if self.is_pending:
2241
            m = _("%s: attempt to deny leave request while is pending") % (
2242
                self,)
2243
            raise AssertionError(m)
2244

    
2245
        if not self.can_deny_leave():
2246
            m = _("%s: attempt to deny leave request in state '%s'") % (
2247
                self, self.state)
2248
            raise AssertionError(m)
2249

    
2250
        self.leave_request_date = None
2251
        self.state = self.ACCEPTED
2252
        self.save()
2253

    
2254
    def can_cancel_leave(self):
2255
        return self.state == self.LEAVE_REQUESTED
2256

    
2257
    def leave_request_cancel(self):
2258
        if self.is_pending:
2259
            m = _("%s: attempt to cancel leave request while is pending") % (
2260
                self,)
2261
            raise AssertionError(m)
2262

    
2263
        if not self.can_cancel_leave():
2264
            m = _("%s: attempt to cancel leave request in state '%s'") % (
2265
                self, self.state)
2266
            raise AssertionError(m)
2267

    
2268
        self.leave_request_date = None
2269
        self.state = self.ACCEPTED
2270
        self.save()
2271

    
2272
    def can_remove(self):
2273
        return self.state in self.ACCEPTED_STATES
2274

    
2275
    def remove(self):
2276
        if self.is_pending:
2277
            m = _("%s: attempt to remove while is pending") % (self,)
2278
            raise AssertionError(m)
2279

    
2280
        if not self.can_remove():
2281
            m = _("%s: attempt to remove in state '%s'") % (self, self.state)
2282
            raise AssertionError(m)
2283

    
2284
        self._set_history_item(reason='REMOVE')
2285
        self.state = self.REMOVED
2286
        self.is_pending = True
2287
        self.save()
2288

    
2289
    def can_reject(self):
2290
        return self.state == self.REQUESTED
2291

    
2292
    def reject(self):
2293
        if self.is_pending:
2294
            m = _("%s: attempt to reject while is pending") % (self,)
2295
            raise AssertionError(m)
2296

    
2297
        if not self.can_reject():
2298
            m = _("%s: attempt to reject in state '%s'") % (self, self.state)
2299
            raise AssertionError(m)
2300

    
2301
        # rejected requests don't need sync,
2302
        # because they were never effected
2303
        self._set_history_item(reason='REJECT')
2304
        self.delete()
2305

    
2306
    def can_cancel(self):
2307
        return self.state == self.REQUESTED
2308

    
2309
    def cancel(self):
2310
        if self.is_pending:
2311
            m = _("%s: attempt to cancel while is pending") % (self,)
2312
            raise AssertionError(m)
2313

    
2314
        if not self.can_cancel():
2315
            m = _("%s: attempt to cancel in state '%s'") % (self, self.state)
2316
            raise AssertionError(m)
2317

    
2318
        # rejected requests don't need sync,
2319
        # because they were never effected
2320
        self._set_history_item(reason='CANCEL')
2321
        self.delete()
2322

    
2323
    def get_diff_quotas(self, sub_list=None, add_list=None):
2324
        if sub_list is None:
2325
            sub_list = []
2326

    
2327
        if add_list is None:
2328
            add_list = []
2329

    
2330
        sub_append = sub_list.append
2331
        add_append = add_list.append
2332
        holder = self.person.uuid
2333

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

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

    
2356
        return (sub_list, add_list)
2357

    
2358
    def set_sync(self):
2359
        if not self.is_pending:
2360
            m = _("%s: attempt to sync a non pending membership") % (self,)
2361
            raise AssertionError(m)
2362

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

    
2371
            self.application = pending_application
2372
            self.is_active = True
2373

    
2374
            self.pending_application = None
2375
            self.pending_serial = None
2376

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

    
2384
        elif state == self.PROJECT_DEACTIVATED:
2385
            if self.pending_application:
2386
                m = _("%s: attempt to sync in state '%s' "
2387
                      "with a pending application") % (self, state)
2388
                raise AssertionError(m)
2389

    
2390
            self.application = None
2391
            self.is_active = False
2392
            self.pending_serial = None
2393
            self.is_pending = False
2394
            self.save()
2395

    
2396
        elif state == self.REMOVED:
2397
            self.delete()
2398

    
2399
        else:
2400
            m = _("%s: attempt to sync in state '%s'") % (self, state)
2401
            raise AssertionError(m)
2402

    
2403
    def reset_sync(self):
2404
        if not self.is_pending:
2405
            m = _("%s: attempt to reset a non pending membership") % (self,)
2406
            raise AssertionError(m)
2407

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

    
2418
class Serial(models.Model):
2419
    serial  =   models.AutoField(primary_key=True)
2420

    
2421
def new_serial():
2422
    s = Serial.objects.create()
2423
    serial = s.serial
2424
    s.delete()
2425
    return serial
2426

    
2427
def sync_finish_serials(serials_to_ack=None):
2428
    if serials_to_ack is None:
2429
        serials_to_ack = qh_query_serials([])
2430

    
2431
    serials_to_ack = set(serials_to_ack)
2432
    sfu = ProjectMembership.objects.select_for_update()
2433
    memberships = list(sfu.filter(pending_serial__isnull=False))
2434

    
2435
    if memberships:
2436
        for membership in memberships:
2437
            serial = membership.pending_serial
2438
            if serial in serials_to_ack:
2439
                membership.set_sync()
2440
            else:
2441
                membership.reset_sync()
2442

    
2443
        transaction.commit()
2444

    
2445
    qh_ack_serials(list(serials_to_ack))
2446
    return len(memberships)
2447

    
2448
def pre_sync_projects(sync=True):
2449
    ACCEPTED = ProjectMembership.ACCEPTED
2450
    LEAVE_REQUESTED = ProjectMembership.LEAVE_REQUESTED
2451
    PROJECT_DEACTIVATED = ProjectMembership.PROJECT_DEACTIVATED
2452
    psfu = Project.objects.select_for_update()
2453

    
2454
    modified = list(psfu.modified_projects())
2455
    if sync:
2456
        for project in modified:
2457
            objects = project.projectmembership_set.select_for_update()
2458

    
2459
            memberships = objects.actually_accepted()
2460
            for membership in memberships:
2461
                membership.is_pending = True
2462
                membership.save()
2463

    
2464
    reactivating = list(psfu.reactivating_projects())
2465
    if sync:
2466
        for project in reactivating:
2467
            objects = project.projectmembership_set.select_for_update()
2468

    
2469
            memberships = objects.filter(state=PROJECT_DEACTIVATED)
2470
            for membership in memberships:
2471
                membership.is_pending = True
2472
                if membership.leave_request_date is None:
2473
                    membership.state = ACCEPTED
2474
                else:
2475
                    membership.state = LEAVE_REQUESTED
2476
                membership.save()
2477

    
2478
    deactivating = list(psfu.deactivating_projects())
2479
    if sync:
2480
        for project in deactivating:
2481
            objects = project.projectmembership_set.select_for_update()
2482

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

    
2491
    return (modified, reactivating, deactivating)
2492

    
2493
def do_sync_projects():
2494

    
2495
    ACTUALLY_ACCEPTED = ProjectMembership.ACTUALLY_ACCEPTED
2496
    objects = ProjectMembership.objects.select_for_update()
2497

    
2498
    sub_quota, add_quota = [], []
2499

    
2500
    serial = new_serial()
2501

    
2502
    pending = objects.filter(is_pending=True)
2503
    for membership in pending:
2504

    
2505
        if membership.pending_application:
2506
            m = "%s: impossible: pending_application is not None (%s)" % (
2507
                membership, membership.pending_application)
2508
            raise AssertionError(m)
2509
        if membership.pending_serial:
2510
            m = "%s: impossible: pending_serial is not None (%s)" % (
2511
                membership, membership.pending_serial)
2512
            raise AssertionError(m)
2513

    
2514
        if membership.state in ACTUALLY_ACCEPTED:
2515
            membership.pending_application = membership.project.application
2516

    
2517
        membership.pending_serial = serial
2518
        membership.get_diff_quotas(sub_quota, add_quota)
2519
        membership.save()
2520

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

    
2527
    r = qh_add_quota(serial, sub_quota, add_quota)
2528
    if r:
2529
        m = "cannot sync serial: %d" % serial
2530
        raise RuntimeError(m)
2531

    
2532
    return serial
2533

    
2534
def post_sync_projects():
2535
    PROJECT_DEACTIVATED = ProjectMembership.PROJECT_DEACTIVATED
2536
    Q_ACTUALLY_ACCEPTED = ProjectMembership.Q_ACTUALLY_ACCEPTED
2537
    psfu = Project.objects.select_for_update()
2538

    
2539
    modified = psfu.modified_projects()
2540
    for project in modified:
2541
        objects = project.projectmembership_set.select_for_update()
2542

    
2543
        memberships = list(objects.filter(Q_ACTUALLY_ACCEPTED &
2544
                                          Q(is_pending=True)))
2545
        if not memberships:
2546
            project.is_modified = False
2547
            project.save()
2548

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

    
2558
    deactivating = psfu.deactivating_projects()
2559
    for project in deactivating:
2560
        objects = project.projectmembership_set.select_for_update()
2561

    
2562
        memberships = list(objects.filter(Q_ACTUALLY_ACCEPTED |
2563
                                          Q(is_pending=True)))
2564
        if not memberships:
2565
            project.deactivate()
2566
            project.save()
2567

    
2568
    transaction.commit()
2569

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

    
2577
        projects_log = pre_sync_projects(sync)
2578
        if sync:
2579
            serial = do_sync_projects()
2580
            sync_finish_serials([serial])
2581
            post_sync_projects()
2582

    
2583
        return (pending, projects_log)
2584
    return _sync_projects(sync)
2585

    
2586
def all_users_quotas(users):
2587
    quotas = {}
2588
    for user in users:
2589
        quotas[user.uuid] = user.all_quotas()
2590
    return quotas
2591

    
2592
def sync_users(users, sync=True, retries=3, retry_wait=1.0):
2593
    @with_lock(retries, retry_wait)
2594
    def _sync_users(users, sync):
2595
        sync_finish_serials()
2596

    
2597
        existing, nonexisting = qh_check_users(users)
2598
        resources = get_resource_names()
2599
        registered_quotas = qh_get_quota_limits(existing, resources)
2600
        astakos_quotas = all_users_quotas(users)
2601

    
2602
        if sync:
2603
            r = register_users(nonexisting)
2604
            r = send_quotas(astakos_quotas)
2605

    
2606
        return (existing, nonexisting, registered_quotas, astakos_quotas)
2607
    return _sync_users(users, sync)
2608

    
2609
def sync_all_users(sync=True, retries=3, retry_wait=1.0):
2610
    users = AstakosUser.objects.filter(is_active=True)
2611
    return sync_users(users, sync, retries, retry_wait)
2612

    
2613
class ProjectMembershipHistory(models.Model):
2614
    reasons_list    =   ['ACCEPT', 'REJECT', 'REMOVE']
2615
    reasons         =   dict((k, v) for v, k in enumerate(reasons_list))
2616

    
2617
    person  =   models.BigIntegerField()
2618
    project =   models.BigIntegerField()
2619
    date    =   models.DateField(auto_now_add=True)
2620
    reason  =   models.IntegerField()
2621
    serial  =   models.BigIntegerField()
2622

    
2623
### SIGNALS ###
2624
################
2625

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

    
2638
def fix_superusers():
2639
    # Associate superusers with AstakosUser
2640
    admins = User.objects.filter(is_superuser=True)
2641
    for u in admins:
2642
        create_astakos_user(u)
2643

    
2644
def user_post_save(sender, instance, created, **kwargs):
2645
    if not created:
2646
        return
2647
    create_astakos_user(instance)
2648
post_save.connect(user_post_save, sender=User)
2649

    
2650
def astakosuser_post_save(sender, instance, created, **kwargs):
2651
    pass
2652

    
2653
post_save.connect(astakosuser_post_save, sender=AstakosUser)
2654

    
2655
def resource_post_save(sender, instance, created, **kwargs):
2656
    pass
2657

    
2658
post_save.connect(resource_post_save, sender=Resource)
2659

    
2660
def renew_token(sender, instance, **kwargs):
2661
    if not instance.auth_token:
2662
        instance.renew_token()
2663
pre_save.connect(renew_token, sender=AstakosUser)
2664
pre_save.connect(renew_token, sender=Service)