Statistics
| Branch: | Tag: | Revision:

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

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

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

    
47
from django.db import models, IntegrityError, transaction, connection
48
from django.contrib.auth.models import User, UserManager, Group, Permission
49
from django.utils.translation import ugettext as _
50
from django.db import transaction
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)
71
from astakos.im import settings as astakos_settings
72
from astakos.im.endpoints.qh import (
73
    register_users, register_resources, qh_add_quota, QuotaLimits,
74
    qh_query_serials, qh_ack_serials)
75
from astakos.im import auth_providers
76

    
77
import astakos.im.messages as astakos_messages
78
from .managers import ForUpdateManager
79

    
80
logger = logging.getLogger(__name__)
81

    
82
DEFAULT_CONTENT_TYPE = None
83
_content_type = None
84

    
85
def get_content_type():
86
    global _content_type
87
    if _content_type is not None:
88
        return _content_type
89

    
90
    try:
91
        content_type = ContentType.objects.get(app_label='im', model='astakosuser')
92
    except:
93
        content_type = DEFAULT_CONTENT_TYPE
94
    _content_type = content_type
95
    return content_type
96

    
97
RESOURCE_SEPARATOR = '.'
98

    
99
inf = float('inf')
100

    
101
class Service(models.Model):
102
    name = models.CharField(_('Name'), max_length=255, unique=True, db_index=True)
103
    url = models.FilePathField()
104
    icon = models.FilePathField(blank=True)
105
    auth_token = models.CharField(_('Authentication Token'), max_length=32,
106
                                  null=True, blank=True)
107
    auth_token_created = models.DateTimeField(_('Token creation date'), null=True)
108
    auth_token_expires = models.DateTimeField(
109
        _('Token expiration date'), null=True)
110

    
111
    def renew_token(self, expiration_date=None):
112
        md5 = hashlib.md5()
113
        md5.update(self.name.encode('ascii', 'ignore'))
114
        md5.update(self.url.encode('ascii', 'ignore'))
115
        md5.update(asctime())
116

    
117
        self.auth_token = b64encode(md5.digest())
118
        self.auth_token_created = datetime.now()
119
        if expiration_date:
120
            self.auth_token_expires = expiration_date
121
        else:
122
            self.auth_token_expires = None
123

    
124
    def __str__(self):
125
        return self.name
126

    
127
    @property
128
    def resources(self):
129
        return self.resource_set.all()
130

    
131
    @resources.setter
132
    def resources(self, resources):
133
        for s in resources:
134
            self.resource_set.create(**s)
135

    
136
    def add_resource(self, service, resource, uplimit, update=True):
137
        """Raises ObjectDoesNotExist, IntegrityError"""
138
        resource = Resource.objects.get(service__name=service, name=resource)
139
        if update:
140
            AstakosUserQuota.objects.update_or_create(user=self,
141
                                                      resource=resource,
142
                                                      defaults={'uplimit': uplimit})
143
        else:
144
            q = self.astakosuserquota_set
145
            q.create(resource=resource, uplimit=uplimit)
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

    
153
class Resource(models.Model):
154
    name = models.CharField(_('Name'), max_length=255)
155
    meta = models.ManyToManyField(ResourceMetadata)
156
    service = models.ForeignKey(Service)
157
    desc = models.TextField(_('Description'), null=True)
158
    unit = models.CharField(_('Name'), null=True, max_length=255)
159
    group = models.CharField(_('Group'), null=True, max_length=255)
160

    
161
    class Meta:
162
        unique_together = ("name", "service")
163

    
164
    def __str__(self):
165
        return '%s%s%s' % (self.service, RESOURCE_SEPARATOR, self.name)
166

    
167
_default_quota = {}
168
def get_default_quota():
169
    global _default_quota
170
    if _default_quota:
171
        return _default_quota
172
    for s, data in SERVICES.iteritems():
173
        map(
174
            lambda d:_default_quota.update(
175
                {'%s%s%s' % (s, RESOURCE_SEPARATOR, d.get('name')):d.get('uplimit', 0)}
176
            ),
177
            data.get('resources', {})
178
        )
179
    return _default_quota
180

    
181
class AstakosUserManager(UserManager):
182

    
183
    def get_auth_provider_user(self, provider, **kwargs):
184
        """
185
        Retrieve AstakosUser instance associated with the specified third party
186
        id.
187
        """
188
        kwargs = dict(map(lambda x: ('auth_providers__%s' % x[0], x[1]),
189
                          kwargs.iteritems()))
190
        return self.get(auth_providers__module=provider, **kwargs)
191

    
192
    def get_by_email(self, email):
193
        return self.get(email=email)
194

    
195
    def get_by_identifier(self, email_or_username, **kwargs):
196
        try:
197
            return self.get(email__iexact=email_or_username, **kwargs)
198
        except AstakosUser.DoesNotExist:
199
            return self.get(username__iexact=email_or_username, **kwargs)
200

    
201
    def user_exists(self, email_or_username, **kwargs):
202
        qemail = Q(email__iexact=email_or_username)
203
        qusername = Q(username__iexact=email_or_username)
204
        return self.filter(qemail | qusername).exists()
205

    
206

    
207
class AstakosUser(User):
208
    """
209
    Extends ``django.contrib.auth.models.User`` by defining additional fields.
210
    """
211
    affiliation = models.CharField(_('Affiliation'), max_length=255, blank=True,
212
                                   null=True)
213

    
214
    # DEPRECATED FIELDS: provider, third_party_identifier moved in
215
    #                    AstakosUserProvider model.
216
    provider = models.CharField(_('Provider'), max_length=255, blank=True,
217
                                null=True)
218
    # ex. screen_name for twitter, eppn for shibboleth
219
    third_party_identifier = models.CharField(_('Third-party identifier'),
220
                                              max_length=255, null=True,
221
                                              blank=True)
222

    
223

    
224
    #for invitations
225
    user_level = DEFAULT_USER_LEVEL
226
    level = models.IntegerField(_('Inviter level'), default=user_level)
227
    invitations = models.IntegerField(
228
        _('Invitations left'), default=INVITATIONS_PER_LEVEL.get(user_level, 0))
229

    
230
    auth_token = models.CharField(_('Authentication Token'), max_length=32,
231
                                  null=True, blank=True)
232
    auth_token_created = models.DateTimeField(_('Token creation date'), null=True)
233
    auth_token_expires = models.DateTimeField(
234
        _('Token expiration date'), null=True)
235

    
236
    updated = models.DateTimeField(_('Update date'))
237
    is_verified = models.BooleanField(_('Is verified?'), default=False)
238

    
239
    email_verified = models.BooleanField(_('Email verified?'), default=False)
240

    
241
    has_credits = models.BooleanField(_('Has credits?'), default=False)
242
    has_signed_terms = models.BooleanField(
243
        _('I agree with the terms'), default=False)
244
    date_signed_terms = models.DateTimeField(
245
        _('Signed terms date'), null=True, blank=True)
246

    
247
    activation_sent = models.DateTimeField(
248
        _('Activation sent data'), null=True, blank=True)
249

    
250
    policy = models.ManyToManyField(
251
        Resource, null=True, through='AstakosUserQuota')
252

    
253
    uuid = models.CharField(max_length=255, null=True, blank=False, unique=True)
254

    
255
    __has_signed_terms = False
256
    disturbed_quota = models.BooleanField(_('Needs quotaholder syncing'),
257
                                           default=False, db_index=True)
258

    
259
    objects = AstakosUserManager()
260

    
261
    def __init__(self, *args, **kwargs):
262
        super(AstakosUser, self).__init__(*args, **kwargs)
263
        self.__has_signed_terms = self.has_signed_terms
264
        if not self.id:
265
            self.is_active = False
266

    
267
    @property
268
    def realname(self):
269
        return '%s %s' % (self.first_name, self.last_name)
270

    
271
    @realname.setter
272
    def realname(self, value):
273
        parts = value.split(' ')
274
        if len(parts) == 2:
275
            self.first_name = parts[0]
276
            self.last_name = parts[1]
277
        else:
278
            self.last_name = parts[0]
279

    
280
    def add_permission(self, pname):
281
        if self.has_perm(pname):
282
            return
283
        p, created = Permission.objects.get_or_create(
284
                                    codename=pname,
285
                                    name=pname.capitalize(),
286
                                    content_type=get_content_type())
287
        self.user_permissions.add(p)
288

    
289
    def remove_permission(self, pname):
290
        if self.has_perm(pname):
291
            return
292
        p = Permission.objects.get(codename=pname,
293
                                   content_type=get_content_type())
294
        self.user_permissions.remove(p)
295

    
296
    @property
297
    def invitation(self):
298
        try:
299
            return Invitation.objects.get(username=self.email)
300
        except Invitation.DoesNotExist:
301
            return None
302

    
303
    @property
304
    def quota(self):
305
        """Returns a dict with the sum of quota limits per resource"""
306
        d = defaultdict(int)
307
        default_quota = get_default_quota()
308
        d.update(default_quota)
309
        for q in self.policies:
310
            d[q.resource] += q.uplimit or inf
311
        for m in self.projectmembership_set.select_related().all():
312
            if not m.acceptance_date:
313
                continue
314
            p = m.project
315
            if not p.is_active:
316
                continue
317
            grants = p.application.projectresourcegrant_set.all()
318
            for g in grants:
319
                d[str(g.resource)] += g.member_capacity or inf
320
        # TODO set default for remaining
321
        return d
322

    
323
    @property
324
    def policies(self):
325
        return self.astakosuserquota_set.select_related().all()
326

    
327
    @policies.setter
328
    def policies(self, policies):
329
        for p in policies:
330
            service = policies.get('service', None)
331
            resource = policies.get('resource', None)
332
            uplimit = policies.get('uplimit', 0)
333
            update = policies.get('update', True)
334
            self.add_policy(service, resource, uplimit, update)
335

    
336
    def add_policy(self, service, resource, uplimit, update=True):
337
        """Raises ObjectDoesNotExist, IntegrityError"""
338
        resource = Resource.objects.get(service__name=service, name=resource)
339
        if update:
340
            AstakosUserQuota.objects.update_or_create(user=self,
341
                                                      resource=resource,
342
                                                      defaults={'uplimit': uplimit})
343
        else:
344
            q = self.astakosuserquota_set
345
            q.create(resource=resource, uplimit=uplimit)
346

    
347
    def remove_policy(self, service, resource):
348
        """Raises ObjectDoesNotExist, IntegrityError"""
349
        resource = Resource.objects.get(service__name=service, name=resource)
350
        q = self.policies.get(resource=resource).delete()
351

    
352
    def update_uuid(self):
353
        while not self.uuid:
354
            uuid_val =  str(uuid.uuid4())
355
            try:
356
                AstakosUser.objects.get(uuid=uuid_val)
357
            except AstakosUser.DoesNotExist, e:
358
                self.uuid = uuid_val
359
        return self.uuid
360

    
361
    @property
362
    def extended_groups(self):
363
        return self.membership_set.select_related().all()
364

    
365
    def save(self, update_timestamps=True, **kwargs):
366
        if update_timestamps:
367
            if not self.id:
368
                self.date_joined = datetime.now()
369
            self.updated = datetime.now()
370

    
371
        # update date_signed_terms if necessary
372
        if self.__has_signed_terms != self.has_signed_terms:
373
            self.date_signed_terms = datetime.now()
374

    
375
        self.update_uuid()
376

    
377
        if self.username != self.email.lower():
378
            # set username
379
            self.username = self.email.lower()
380

    
381
        self.validate_unique_email_isactive()
382

    
383
        super(AstakosUser, self).save(**kwargs)
384

    
385
    def renew_token(self, flush_sessions=False, current_key=None):
386
        md5 = hashlib.md5()
387
        md5.update(settings.SECRET_KEY)
388
        md5.update(self.username)
389
        md5.update(self.realname.encode('ascii', 'ignore'))
390
        md5.update(asctime())
391

    
392
        self.auth_token = b64encode(md5.digest())
393
        self.auth_token_created = datetime.now()
394
        self.auth_token_expires = self.auth_token_created + \
395
                                  timedelta(hours=AUTH_TOKEN_DURATION)
396
        if flush_sessions:
397
            self.flush_sessions(current_key)
398
        msg = 'Token renewed for %s' % self.email
399
        logger.log(LOGGING_LEVEL, msg)
400

    
401
    def flush_sessions(self, current_key=None):
402
        q = self.sessions
403
        if current_key:
404
            q = q.exclude(session_key=current_key)
405

    
406
        keys = q.values_list('session_key', flat=True)
407
        if keys:
408
            msg = 'Flushing sessions: %s' % ','.join(keys)
409
            logger.log(LOGGING_LEVEL, msg, [])
410
        engine = import_module(settings.SESSION_ENGINE)
411
        for k in keys:
412
            s = engine.SessionStore(k)
413
            s.flush()
414

    
415
    def __unicode__(self):
416
        return '%s (%s)' % (self.realname, self.email)
417

    
418
    def conflicting_email(self):
419
        q = AstakosUser.objects.exclude(username=self.username)
420
        q = q.filter(email__iexact=self.email)
421
        if q.count() != 0:
422
            return True
423
        return False
424

    
425
    def validate_unique_email_isactive(self):
426
        """
427
        Implements a unique_together constraint for email and is_active fields.
428
        """
429
        q = AstakosUser.objects.all()
430
        q = q.filter(email = self.email)
431
        if self.id:
432
            q = q.filter(~Q(id = self.id))
433
        if q.count() != 0:
434
            m = 'Another account with the same email = %(email)s & \
435
                is_active = %(is_active)s found.' % self.__dict__
436
            raise ValidationError(m)
437

    
438
    def email_change_is_pending(self):
439
        return self.emailchanges.count() > 0
440

    
441
    def email_change_is_pending(self):
442
        return self.emailchanges.count() > 0
443

    
444
    @property
445
    def signed_terms(self):
446
        term = get_latest_terms()
447
        if not term:
448
            return True
449
        if not self.has_signed_terms:
450
            return False
451
        if not self.date_signed_terms:
452
            return False
453
        if self.date_signed_terms < term.date:
454
            self.has_signed_terms = False
455
            self.date_signed_terms = None
456
            self.save()
457
            return False
458
        return True
459

    
460
    def set_invitations_level(self):
461
        """
462
        Update user invitation level
463
        """
464
        level = self.invitation.inviter.level + 1
465
        self.level = level
466
        self.invitations = INVITATIONS_PER_LEVEL.get(level, 0)
467

    
468
    def can_login_with_auth_provider(self, provider):
469
        if not self.has_auth_provider(provider):
470
            return False
471
        else:
472
            return auth_providers.get_provider(provider).is_available_for_login()
473

    
474
    def can_add_auth_provider(self, provider, **kwargs):
475
        provider_settings = auth_providers.get_provider(provider)
476

    
477
        if not provider_settings.is_available_for_add():
478
            return False
479

    
480
        if self.has_auth_provider(provider) and \
481
           provider_settings.one_per_user:
482
            return False
483

    
484
        if 'provider_info' in kwargs:
485
            kwargs.pop('provider_info')
486

    
487
        if 'identifier' in kwargs:
488
            try:
489
                # provider with specified params already exist
490
                existing_user = AstakosUser.objects.get_auth_provider_user(provider,
491
                                                                   **kwargs)
492
            except AstakosUser.DoesNotExist:
493
                return True
494
            else:
495
                return False
496

    
497
        return True
498

    
499
    def can_remove_auth_provider(self, module):
500
        provider = auth_providers.get_provider(module)
501
        existing = self.get_active_auth_providers()
502
        existing_for_provider = self.get_active_auth_providers(module=module)
503

    
504
        if len(existing) <= 1:
505
            return False
506

    
507
        if len(existing_for_provider) == 1 and provider.is_required():
508
            return False
509

    
510
        return True
511

    
512
    def can_change_password(self):
513
        return self.has_auth_provider('local', auth_backend='astakos')
514

    
515
    def has_required_auth_providers(self):
516
        required = auth_providers.REQUIRED_PROVIDERS
517
        for provider in required:
518
            if not self.has_auth_provider(provider):
519
                return False
520
        return True
521

    
522
    def has_auth_provider(self, provider, **kwargs):
523
        return bool(self.auth_providers.filter(module=provider,
524
                                               **kwargs).count())
525

    
526
    def add_auth_provider(self, provider, **kwargs):
527
        info_data = ''
528
        if 'provider_info' in kwargs:
529
            info_data = kwargs.pop('provider_info')
530
            if isinstance(info_data, dict):
531
                info_data = json.dumps(info_data)
532

    
533
        if self.can_add_auth_provider(provider, **kwargs):
534
            self.auth_providers.create(module=provider, active=True,
535
                                       info_data=info_data,
536
                                       **kwargs)
537
        else:
538
            raise Exception('Cannot add provider')
539

    
540
    def add_pending_auth_provider(self, pending):
541
        """
542
        Convert PendingThirdPartyUser object to AstakosUserAuthProvider entry for
543
        the current user.
544
        """
545
        if not isinstance(pending, PendingThirdPartyUser):
546
            pending = PendingThirdPartyUser.objects.get(token=pending)
547

    
548
        provider = self.add_auth_provider(pending.provider,
549
                               identifier=pending.third_party_identifier,
550
                                affiliation=pending.affiliation,
551
                                          provider_info=pending.info)
552

    
553
        if email_re.match(pending.email or '') and pending.email != self.email:
554
            self.additionalmail_set.get_or_create(email=pending.email)
555

    
556
        pending.delete()
557
        return provider
558

    
559
    def remove_auth_provider(self, provider, **kwargs):
560
        self.auth_providers.get(module=provider, **kwargs).delete()
561

    
562
    # user urls
563
    def get_resend_activation_url(self):
564
        return reverse('send_activation', kwargs={'user_id': self.pk})
565

    
566
    def get_provider_remove_url(self, module, **kwargs):
567
        return reverse('remove_auth_provider', kwargs={
568
            'pk': self.auth_providers.get(module=module, **kwargs).pk})
569

    
570
    def get_activation_url(self, nxt=False):
571
        url = "%s?auth=%s" % (reverse('astakos.im.views.activate'),
572
                                 quote(self.auth_token))
573
        if nxt:
574
            url += "&next=%s" % quote(nxt)
575
        return url
576

    
577
    def get_password_reset_url(self, token_generator=default_token_generator):
578
        return reverse('django.contrib.auth.views.password_reset_confirm',
579
                          kwargs={'uidb36':int_to_base36(self.id),
580
                                  'token':token_generator.make_token(self)})
581

    
582
    def get_auth_providers(self):
583
        return self.auth_providers.all()
584

    
585
    def get_available_auth_providers(self):
586
        """
587
        Returns a list of providers available for user to connect to.
588
        """
589
        providers = []
590
        for module, provider_settings in auth_providers.PROVIDERS.iteritems():
591
            if self.can_add_auth_provider(module):
592
                providers.append(provider_settings(self))
593

    
594
        return providers
595

    
596
    def get_active_auth_providers(self, **filters):
597
        providers = []
598
        for provider in self.auth_providers.active(**filters):
599
            if auth_providers.get_provider(provider.module).is_available_for_login():
600
                providers.append(provider)
601
        return providers
602

    
603
    @property
604
    def auth_providers_display(self):
605
        return ",".join(map(lambda x:unicode(x), self.auth_providers.active()))
606

    
607
    def get_inactive_message(self):
608
        msg_extra = ''
609
        message = ''
610
        if self.activation_sent:
611
            if self.email_verified:
612
                message = _(astakos_messages.ACCOUNT_INACTIVE)
613
            else:
614
                message = _(astakos_messages.ACCOUNT_PENDING_ACTIVATION)
615
                if astakos_settings.MODERATION_ENABLED:
616
                    msg_extra = _(astakos_messages.ACCOUNT_PENDING_ACTIVATION_HELP)
617
                else:
618
                    url = self.get_resend_activation_url()
619
                    msg_extra = mark_safe(_(astakos_messages.ACCOUNT_PENDING_ACTIVATION_HELP) + \
620
                                u' ' + \
621
                                _('<a href="%s">%s?</a>') % (url,
622
                                _(astakos_messages.ACCOUNT_RESEND_ACTIVATION_PROMPT)))
623
        else:
624
            if astakos_settings.MODERATION_ENABLED:
625
                message = _(astakos_messages.ACCOUNT_PENDING_MODERATION)
626
            else:
627
                message = astakos_messages.ACCOUNT_PENDING_ACTIVATION
628
                url = self.get_resend_activation_url()
629
                msg_extra = mark_safe(_('<a href="%s">%s?</a>') % (url,
630
                            _(astakos_messages.ACCOUNT_RESEND_ACTIVATION_PROMPT)))
631

    
632
        return mark_safe(message + u' '+ msg_extra)
633

    
634

    
635
class AstakosUserAuthProviderManager(models.Manager):
636

    
637
    def active(self, **filters):
638
        return self.filter(active=True, **filters)
639

    
640

    
641
class AstakosUserAuthProvider(models.Model):
642
    """
643
    Available user authentication methods.
644
    """
645
    affiliation = models.CharField(_('Affiliation'), max_length=255, blank=True,
646
                                   null=True, default=None)
647
    user = models.ForeignKey(AstakosUser, related_name='auth_providers')
648
    module = models.CharField(_('Provider'), max_length=255, blank=False,
649
                                default='local')
650
    identifier = models.CharField(_('Third-party identifier'),
651
                                              max_length=255, null=True,
652
                                              blank=True)
653
    active = models.BooleanField(default=True)
654
    auth_backend = models.CharField(_('Backend'), max_length=255, blank=False,
655
                                   default='astakos')
656
    info_data = models.TextField(default="", null=True, blank=True)
657
    created = models.DateTimeField('Creation date', auto_now_add=True)
658

    
659
    objects = AstakosUserAuthProviderManager()
660

    
661
    class Meta:
662
        unique_together = (('identifier', 'module', 'user'), )
663
        ordering = ('module', 'created')
664

    
665
    def __init__(self, *args, **kwargs):
666
        super(AstakosUserAuthProvider, self).__init__(*args, **kwargs)
667
        try:
668
            self.info = json.loads(self.info_data)
669
            if not self.info:
670
                self.info = {}
671
        except Exception, e:
672
            self.info = {}
673

    
674
        for key,value in self.info.iteritems():
675
            setattr(self, 'info_%s' % key, value)
676

    
677

    
678
    @property
679
    def settings(self):
680
        return auth_providers.get_provider(self.module)
681

    
682
    @property
683
    def details_display(self):
684
        try:
685
          return self.settings.get_details_tpl_display % self.__dict__
686
        except:
687
          return ''
688

    
689
    @property
690
    def title_display(self):
691
        title_tpl = self.settings.get_title_display
692
        try:
693
            if self.settings.get_user_title_display:
694
                title_tpl = self.settings.get_user_title_display
695
        except Exception, e:
696
            pass
697
        try:
698
          return title_tpl % self.__dict__
699
        except:
700
          return self.settings.get_title_display % self.__dict__
701

    
702
    def can_remove(self):
703
        return self.user.can_remove_auth_provider(self.module)
704

    
705
    def delete(self, *args, **kwargs):
706
        ret = super(AstakosUserAuthProvider, self).delete(*args, **kwargs)
707
        if self.module == 'local':
708
            self.user.set_unusable_password()
709
            self.user.save()
710
        return ret
711

    
712
    def __repr__(self):
713
        return '<AstakosUserAuthProvider %s:%s>' % (self.module, self.identifier)
714

    
715
    def __unicode__(self):
716
        if self.identifier:
717
            return "%s:%s" % (self.module, self.identifier)
718
        if self.auth_backend:
719
            return "%s:%s" % (self.module, self.auth_backend)
720
        return self.module
721

    
722
    def save(self, *args, **kwargs):
723
        self.info_data = json.dumps(self.info)
724
        return super(AstakosUserAuthProvider, self).save(*args, **kwargs)
725

    
726

    
727
class ExtendedManager(models.Manager):
728
    def _update_or_create(self, **kwargs):
729
        assert kwargs, \
730
            'update_or_create() must be passed at least one keyword argument'
731
        obj, created = self.get_or_create(**kwargs)
732
        defaults = kwargs.pop('defaults', {})
733
        if created:
734
            return obj, True, False
735
        else:
736
            try:
737
                params = dict(
738
                    [(k, v) for k, v in kwargs.items() if '__' not in k])
739
                params.update(defaults)
740
                for attr, val in params.items():
741
                    if hasattr(obj, attr):
742
                        setattr(obj, attr, val)
743
                sid = transaction.savepoint()
744
                obj.save(force_update=True)
745
                transaction.savepoint_commit(sid)
746
                return obj, False, True
747
            except IntegrityError, e:
748
                transaction.savepoint_rollback(sid)
749
                try:
750
                    return self.get(**kwargs), False, False
751
                except self.model.DoesNotExist:
752
                    raise e
753

    
754
    update_or_create = _update_or_create
755

    
756

    
757
class AstakosUserQuota(models.Model):
758
    objects = ExtendedManager()
759
    limit = models.PositiveIntegerField(_('Limit'), null=True)    # obsolete field
760
    uplimit = models.BigIntegerField(_('Up limit'), null=True)
761
    resource = models.ForeignKey(Resource)
762
    user = models.ForeignKey(AstakosUser)
763

    
764
    class Meta:
765
        unique_together = ("resource", "user")
766

    
767

    
768
class ApprovalTerms(models.Model):
769
    """
770
    Model for approval terms
771
    """
772

    
773
    date = models.DateTimeField(
774
        _('Issue date'), db_index=True, default=datetime.now())
775
    location = models.CharField(_('Terms location'), max_length=255)
776

    
777

    
778
class Invitation(models.Model):
779
    """
780
    Model for registring invitations
781
    """
782
    inviter = models.ForeignKey(AstakosUser, related_name='invitations_sent',
783
                                null=True)
784
    realname = models.CharField(_('Real name'), max_length=255)
785
    username = models.CharField(_('Unique ID'), max_length=255, unique=True)
786
    code = models.BigIntegerField(_('Invitation code'), db_index=True)
787
    is_consumed = models.BooleanField(_('Consumed?'), default=False)
788
    created = models.DateTimeField(_('Creation date'), auto_now_add=True)
789
    consumed = models.DateTimeField(_('Consumption date'), null=True, blank=True)
790

    
791
    def __init__(self, *args, **kwargs):
792
        super(Invitation, self).__init__(*args, **kwargs)
793
        if not self.id:
794
            self.code = _generate_invitation_code()
795

    
796
    def consume(self):
797
        self.is_consumed = True
798
        self.consumed = datetime.now()
799
        self.save()
800

    
801
    def __unicode__(self):
802
        return '%s -> %s [%d]' % (self.inviter, self.username, self.code)
803

    
804

    
805
class EmailChangeManager(models.Manager):
806

    
807
    @transaction.commit_on_success
808
    def change_email(self, activation_key):
809
        """
810
        Validate an activation key and change the corresponding
811
        ``User`` if valid.
812

813
        If the key is valid and has not expired, return the ``User``
814
        after activating.
815

816
        If the key is not valid or has expired, return ``None``.
817

818
        If the key is valid but the ``User`` is already active,
819
        return ``None``.
820

821
        After successful email change the activation record is deleted.
822

823
        Throws ValueError if there is already
824
        """
825
        try:
826
            email_change = self.model.objects.get(
827
                activation_key=activation_key)
828
            if email_change.activation_key_expired():
829
                email_change.delete()
830
                raise EmailChange.DoesNotExist
831
            # is there an active user with this address?
832
            try:
833
                AstakosUser.objects.get(email__iexact=email_change.new_email_address)
834
            except AstakosUser.DoesNotExist:
835
                pass
836
            else:
837
                raise ValueError(_('The new email address is reserved.'))
838
            # update user
839
            user = AstakosUser.objects.get(pk=email_change.user_id)
840
            old_email = user.email
841
            user.email = email_change.new_email_address
842
            user.save()
843
            email_change.delete()
844
            msg = "User %d changed email from %s to %s" % (user.pk, old_email,
845
                                                          user.email)
846
            logger.log(LOGGING_LEVEL, msg)
847
            return user
848
        except EmailChange.DoesNotExist:
849
            raise ValueError(_('Invalid activation key.'))
850

    
851

    
852
class EmailChange(models.Model):
853
    new_email_address = models.EmailField(
854
        _(u'new e-mail address'),
855
        help_text=_('Your old email address will be used until you verify your new one.'))
856
    user = models.ForeignKey(
857
        AstakosUser, unique=True, related_name='emailchanges')
858
    requested_at = models.DateTimeField(default=datetime.now())
859
    activation_key = models.CharField(
860
        max_length=40, unique=True, db_index=True)
861

    
862
    objects = EmailChangeManager()
863

    
864
    def get_url(self):
865
        return reverse('email_change_confirm',
866
                      kwargs={'activation_key': self.activation_key})
867

    
868
    def activation_key_expired(self):
869
        expiration_date = timedelta(days=EMAILCHANGE_ACTIVATION_DAYS)
870
        return self.requested_at + expiration_date < datetime.now()
871

    
872

    
873
class AdditionalMail(models.Model):
874
    """
875
    Model for registring invitations
876
    """
877
    owner = models.ForeignKey(AstakosUser)
878
    email = models.EmailField()
879

    
880

    
881
def _generate_invitation_code():
882
    while True:
883
        code = randint(1, 2L ** 63 - 1)
884
        try:
885
            Invitation.objects.get(code=code)
886
            # An invitation with this code already exists, try again
887
        except Invitation.DoesNotExist:
888
            return code
889

    
890

    
891
def get_latest_terms():
892
    try:
893
        term = ApprovalTerms.objects.order_by('-id')[0]
894
        return term
895
    except IndexError:
896
        pass
897
    return None
898

    
899
class PendingThirdPartyUser(models.Model):
900
    """
901
    Model for registring successful third party user authentications
902
    """
903
    third_party_identifier = models.CharField(_('Third-party identifier'), max_length=255, null=True, blank=True)
904
    provider = models.CharField(_('Provider'), max_length=255, blank=True)
905
    email = models.EmailField(_('e-mail address'), blank=True, null=True)
906
    first_name = models.CharField(_('first name'), max_length=30, blank=True)
907
    last_name = models.CharField(_('last name'), max_length=30, blank=True)
908
    affiliation = models.CharField('Affiliation', max_length=255, blank=True)
909
    username = models.CharField(_('username'), max_length=30, unique=True, help_text=_("Required. 30 characters or fewer. Letters, numbers and @/./+/-/_ characters"))
910
    token = models.CharField(_('Token'), max_length=255, null=True, blank=True)
911
    created = models.DateTimeField(auto_now_add=True, null=True, blank=True)
912
    info = models.TextField(default="", null=True, blank=True)
913

    
914
    class Meta:
915
        unique_together = ("provider", "third_party_identifier")
916

    
917
    def get_user_instance(self):
918
        d = self.__dict__
919
        d.pop('_state', None)
920
        d.pop('id', None)
921
        d.pop('token', None)
922
        d.pop('created', None)
923
        d.pop('info', None)
924
        user = AstakosUser(**d)
925

    
926
        return user
927

    
928
    @property
929
    def realname(self):
930
        return '%s %s' %(self.first_name, self.last_name)
931

    
932
    @realname.setter
933
    def realname(self, value):
934
        parts = value.split(' ')
935
        if len(parts) == 2:
936
            self.first_name = parts[0]
937
            self.last_name = parts[1]
938
        else:
939
            self.last_name = parts[0]
940

    
941
    def save(self, **kwargs):
942
        if not self.id:
943
            # set username
944
            while not self.username:
945
                username =  uuid.uuid4().hex[:30]
946
                try:
947
                    AstakosUser.objects.get(username = username)
948
                except AstakosUser.DoesNotExist, e:
949
                    self.username = username
950
        super(PendingThirdPartyUser, self).save(**kwargs)
951

    
952
    def generate_token(self):
953
        self.password = self.third_party_identifier
954
        self.last_login = datetime.now()
955
        self.token = default_token_generator.make_token(self)
956

    
957
class SessionCatalog(models.Model):
958
    session_key = models.CharField(_('session key'), max_length=40)
959
    user = models.ForeignKey(AstakosUser, related_name='sessions', null=True)
960

    
961

    
962
### PROJECTS ###
963
################
964

    
965
class MemberJoinPolicy(models.Model):
966
    policy = models.CharField(_('Policy'), max_length=255, unique=True, db_index=True)
967
    description = models.CharField(_('Description'), max_length=80)
968

    
969
    def __str__(self):
970
        return self.policy
971

    
972
class MemberLeavePolicy(models.Model):
973
    policy = models.CharField(_('Policy'), max_length=255, unique=True, db_index=True)
974
    description = models.CharField(_('Description'), max_length=80)
975

    
976
    def __str__(self):
977
        return self.policy
978

    
979
def synced_model_metaclass(class_name, class_parents, class_attributes):
980

    
981
    new_attributes = {}
982
    sync_attributes = {}
983

    
984
    for name, value in class_attributes.iteritems():
985
        sync, underscore, rest = name.partition('_')
986
        if sync == 'sync' and underscore == '_':
987
            sync_attributes[rest] = value
988
        else:
989
            new_attributes[name] = value
990

    
991
    if 'prefix' not in sync_attributes:
992
        m = ("you did not specify a 'sync_prefix' attribute "
993
             "in class '%s'" % (class_name,))
994
        raise ValueError(m)
995

    
996
    prefix = sync_attributes.pop('prefix')
997
    class_name = sync_attributes.pop('classname', prefix + '_model')
998

    
999
    for name, value in sync_attributes.iteritems():
1000
        newname = prefix + '_' + name
1001
        if newname in new_attributes:
1002
            m = ("class '%s' was specified with prefix '%s' "
1003
                 "but it already has an attribute named '%s'"
1004
                 % (class_name, prefix, newname))
1005
            raise ValueError(m)
1006

    
1007
        new_attributes[newname] = value
1008

    
1009
    newclass = type(class_name, class_parents, new_attributes)
1010
    return newclass
1011

    
1012

    
1013
def make_synced(prefix='sync', name='SyncedState'):
1014

    
1015
    the_name = name
1016
    the_prefix = prefix
1017

    
1018
    class SyncedState(models.Model):
1019

    
1020
        sync_classname      = the_name
1021
        sync_prefix         = the_prefix
1022
        __metaclass__       = synced_model_metaclass
1023

    
1024
        sync_new_state      = models.BigIntegerField(null=True)
1025
        sync_synced_state   = models.BigIntegerField(null=True)
1026
        STATUS_SYNCED       = 0
1027
        STATUS_PENDING      = 1
1028
        sync_status         = models.IntegerField(db_index=True)
1029

    
1030
        class Meta:
1031
            abstract = True
1032

    
1033
        class NotSynced(Exception):
1034
            pass
1035

    
1036
        def sync_init_state(self, state):
1037
            self.sync_synced_state = state
1038
            self.sync_new_state = state
1039
            self.sync_status = self.STATUS_SYNCED
1040

    
1041
        def sync_get_status(self):
1042
            return self.sync_status
1043

    
1044
        def sync_set_status(self):
1045
            if self.sync_new_state != self.sync_synced_state:
1046
                self.sync_status = self.STATUS_PENDING
1047
            else:
1048
                self.sync_status = self.STATUS_SYNCED
1049

    
1050
        def sync_set_synced(self):
1051
            self.sync_synced_state = self.sync_new_state
1052
            self.sync_status = self.STATUS_SYNCED
1053

    
1054
        def sync_get_synced_state(self):
1055
            return self.sync_synced_state
1056

    
1057
        def sync_set_new_state(self, new_state):
1058
            self.sync_new_state = new_state
1059
            self.sync_set_status()
1060

    
1061
        def sync_get_new_state(self):
1062
            return self.sync_new_state
1063

    
1064
        def sync_set_synced_state(self, synced_state):
1065
            self.sync_synced_state = synced_state
1066
            self.sync_set_status()
1067

    
1068
        def sync_get_pending_objects(self):
1069
            kw = dict((the_prefix + '_status', self.STATUS_PENDING))
1070
            return self.objects.filter(**kw)
1071

    
1072
        def sync_get_synced_objects(self):
1073
            kw = dict((the_prefix + '_status', self.STATUS_SYNCED))
1074
            return self.objects.filter(**kw)
1075

    
1076
        def sync_verify_get_synced_state(self):
1077
            status = self.sync_get_status()
1078
            state = self.sync_get_synced_state()
1079
            verified = (status == self.STATUS_SYNCED)
1080
            return state, verified
1081

    
1082
        def sync_is_synced(self):
1083
            state, verified = self.sync_verify_get_synced_state()
1084
            return verified
1085

    
1086
    return SyncedState
1087

    
1088
SyncedState = make_synced(prefix='sync', name='SyncedState')
1089

    
1090

    
1091
class ProjectApplication(models.Model):
1092
    PENDING, APPROVED, REPLACED, UNKNOWN = 'Pending', 'Approved', 'Replaced', 'Unknown'
1093
    applicant               =   models.ForeignKey(
1094
                                    AstakosUser,
1095
                                    related_name='projects_applied',
1096
                                    db_index=True)
1097

    
1098
    state                   =   models.CharField(max_length=80,
1099
                                                default=UNKNOWN)
1100

    
1101
    owner                   =   models.ForeignKey(
1102
                                    AstakosUser,
1103
                                    related_name='projects_owned',
1104
                                    db_index=True)
1105

    
1106
    precursor_application   =   models.OneToOneField('ProjectApplication',
1107
                                                     null=True,
1108
                                                     blank=True,
1109
                                                     db_index=True)
1110

    
1111
    name                    =   models.CharField(max_length=80, help_text=" The Project's name should be in a domain format. The domain shouldn't neccessarily exist in the real world but is helpful to imply a structure. e.g.: myproject.mylab.ntua.gr or myservice.myteam.myorganization ",)
1112
    homepage                =   models.URLField(max_length=255, null=True,
1113
                                                blank=True,help_text="This should be a URL pointing at your project's site. e.g.: http://myproject.com ",)
1114
    description             =   models.TextField(null=True, blank=True,help_text= "Please provide a short but descriptive abstract of your Project, so that anyone searching can quickly understand what this Project is about. ")
1115
    start_date              =   models.DateTimeField(help_text= "Here you specify the date you want your Project to start granting its resources. Its members will get the resources coming from this Project on this exact date.")
1116
    end_date                =   models.DateTimeField(help_text= "Here you specify the date you want your Project to cease. This means that after this date all members will no longer be able to allocate resources from this Project.  ")
1117
    member_join_policy      =   models.ForeignKey(MemberJoinPolicy)
1118
    member_leave_policy     =   models.ForeignKey(MemberLeavePolicy)
1119
    limit_on_members_number =   models.PositiveIntegerField(null=True,
1120
                                                            blank=True,help_text= "Here you specify the number of members this Project is going to have. This means that this number of people will be granted the resources you will specify in the next step. This can be '1' if you are the only one wanting to get resources. ")
1121
    resource_grants         =   models.ManyToManyField(
1122
                                    Resource,
1123
                                    null=True,
1124
                                    blank=True,
1125
                                    through='ProjectResourceGrant')
1126
    comments                =   models.TextField(null=True, blank=True)
1127
    issue_date              =   models.DateTimeField()
1128

    
1129
    def add_resource_policy(self, service, resource, uplimit):
1130
        """Raises ObjectDoesNotExist, IntegrityError"""
1131
        q = self.projectresourcegrant_set
1132
        resource = Resource.objects.get(service__name=service, name=resource)
1133
        q.create(resource=resource, member_capacity=uplimit)
1134

    
1135
    
1136
    @property
1137
    def grants(self):
1138
        return self.projectresourcegrant_set.values('member_capacity', 'resource__name', 'resource__service__name')
1139
            
1140
    @property
1141
    def resource_policies(self):
1142
        return self.projectresourcegrant_set.all()
1143

    
1144
    @resource_policies.setter
1145
    def resource_policies(self, policies):
1146
        for p in policies:
1147
            service = p.get('service', None)
1148
            resource = p.get('resource', None)
1149
            uplimit = p.get('uplimit', 0)
1150
            self.add_resource_policy(service, resource, uplimit)
1151

    
1152
    @property
1153
    def follower(self):
1154
        try:
1155
            return ProjectApplication.objects.get(precursor_application=self)
1156
        except ProjectApplication.DoesNotExist:
1157
            return
1158

    
1159
    def submit(self, resource_policies, applicant, comments,
1160
               precursor_application=None):
1161

    
1162
        if precursor_application:
1163
            self.precursor_application = precursor_application
1164
            self.owner = precursor_application.owner
1165
        else:
1166
            self.owner = applicant
1167

    
1168
        self.id = None
1169
        self.applicant = applicant
1170
        self.comments = comments
1171
        self.issue_date = datetime.now()
1172
        self.state = self.PENDING
1173
        self.save()
1174
        self.resource_policies = resource_policies
1175

    
1176
    def _get_project(self):
1177
        precursor = self
1178
        while precursor:
1179
            try:
1180
                project = precursor.project
1181
                return project
1182
            except Project.DoesNotExist:
1183
                pass
1184
            precursor = precursor.precursor_application
1185

    
1186
        return None
1187

    
1188
    def approve(self, approval_user=None):
1189
        """
1190
        If approval_user then during owner membership acceptance
1191
        it is checked whether the request_user is eligible.
1192

1193
        Raises:
1194
            PermissionDenied
1195
        """
1196

    
1197
        if not transaction.is_managed():
1198
            raise AssertionError("NOPE")
1199

    
1200
        new_project_name = self.name
1201
        if self.state != self.PENDING:
1202
            m = _("cannot approve: project '%s' in state '%s'") % (
1203
                    new_project_name, self.state)
1204
            raise PermissionDenied(m) # invalid argument
1205

    
1206
        now = datetime.now()
1207
        project = self._get_project()
1208

    
1209
        try:
1210
            # needs SERIALIZABLE
1211
            conflicting_project = Project.objects.get(name=new_project_name)
1212
            if (conflicting_project.is_alive and
1213
                conflicting_project != project):
1214
                m = (_("cannot approve: project with name '%s' "
1215
                       "already exists (serial: %s)") % (
1216
                        new_project_name, conflicting_project.id))
1217
                raise PermissionDenied(m) # invalid argument
1218
        except Project.DoesNotExist:
1219
            pass
1220

    
1221
        new_project = False
1222
        if project is None:
1223
            new_project = True
1224
            project = Project(creation_date=now)
1225

    
1226
        project.name = new_project_name
1227
        project.application = self
1228
        project.last_approval_date = now
1229
        project.save()
1230

    
1231
        if new_project:
1232
            project.add_member(self.owner)
1233

    
1234
        # This will block while syncing,
1235
        # but unblock before setting the membership state.
1236
        # See ProjectMembership.set_sync()
1237
        project.set_membership_pending_sync()
1238

    
1239
        precursor = self.precursor_application
1240
        while precursor:
1241
            precursor.state = self.REPLACED
1242
            precursor.save()
1243
            precursor = precursor.precursor_application
1244

    
1245
        self.state = self.APPROVED
1246
        self.save()
1247

    
1248

    
1249
class ProjectResourceGrant(models.Model):
1250

    
1251
    resource                =   models.ForeignKey(Resource)
1252
    project_application     =   models.ForeignKey(ProjectApplication,
1253
                                                  null=True)
1254
    project_capacity        =   models.BigIntegerField(null=True)
1255
    project_import_limit    =   models.BigIntegerField(null=True)
1256
    project_export_limit    =   models.BigIntegerField(null=True)
1257
    member_capacity         =   models.BigIntegerField(null=True)
1258
    member_import_limit     =   models.BigIntegerField(null=True)
1259
    member_export_limit     =   models.BigIntegerField(null=True)
1260

    
1261
    objects = ExtendedManager()
1262

    
1263
    class Meta:
1264
        unique_together = ("resource", "project_application")
1265

    
1266

    
1267
class Project(models.Model):
1268

    
1269
    application                 =   models.OneToOneField(
1270
                                            ProjectApplication,
1271
                                            related_name='project')
1272
    last_approval_date          =   models.DateTimeField(null=True)
1273

    
1274
    members                     =   models.ManyToManyField(
1275
                                            AstakosUser,
1276
                                            through='ProjectMembership')
1277

    
1278
    termination_start_date      =   models.DateTimeField(null=True)
1279
    termination_date            =   models.DateTimeField(null=True)
1280

    
1281
    creation_date               =   models.DateTimeField()
1282
    name                        =   models.CharField(
1283
                                            max_length=80,
1284
                                            db_index=True,
1285
                                            unique=True)
1286

    
1287
    @property
1288
    def violated_resource_grants(self):
1289
        return False
1290

    
1291
    @property
1292
    def violated_members_number_limit(self):
1293
        application = self.application
1294
        return len(self.approved_members) > application.limit_on_members_number
1295

    
1296
    @property
1297
    def is_terminated(self):
1298
        return bool(self.termination_date)
1299

    
1300
    @property
1301
    def is_still_approved(self):
1302
        return bool(self.last_approval_date)
1303

    
1304
    @property
1305
    def is_active(self):
1306
        if (self.is_terminated or
1307
            not self.is_still_approved or
1308
            self.violated_resource_grants):
1309
            return False
1310
#         if self.violated_members_number_limit:
1311
#             return False
1312
        return True
1313

    
1314
    @property
1315
    def is_suspended(self):
1316
        if (self.is_terminated or
1317
            self.is_still_approved or
1318
            not self.violated_resource_grants):
1319
            return False
1320
#             if not self.violated_members_number_limit:
1321
#                 return False
1322
        return True
1323

    
1324
    @property
1325
    def is_alive(self):
1326
        return self.is_active or self.is_suspended
1327

    
1328
    @property
1329
    def is_inconsistent(self):
1330
        now = datetime.now()
1331
        if self.creation_date > now:
1332
            return True
1333
        if self.last_approval_date > now:
1334
            return True
1335
        if self.terminaton_date > now:
1336
            return True
1337
        return False
1338

    
1339
    @property
1340
    def approved_memberships(self):
1341
        ACCEPTED = ProjectMembership.ACCEPTED
1342
        PENDING  = ProjectMembership.PENDING
1343
        return self.projectmembership_set.filter(
1344
            Q(state=ACCEPTED) | Q(state=PENDING))
1345

    
1346
    @property
1347
    def approved_members(self):
1348
        return [m.person for m in self.approved_memberships]
1349

    
1350
    def set_membership_pending_sync(self):
1351
        ACCEPTED = ProjectMembership.ACCEPTED
1352
        PENDING  = ProjectMembership.PENDING
1353
        sfu = self.projectmembership_set.select_for_update()
1354
        members = sfu.filter(Q(state=ACCEPTED) | Q(state=PENDING))
1355

    
1356
        for member in members:
1357
            member.state = member.PENDING
1358
            member.save()
1359

    
1360
    def add_member(self, user):
1361
        """
1362
        Raises:
1363
            django.exceptions.PermissionDenied
1364
            astakos.im.models.AstakosUser.DoesNotExist
1365
        """
1366
        if isinstance(user, int):
1367
            user = AstakosUser.objects.get(user=user)
1368

    
1369
        m, created = ProjectMembership.objects.get_or_create(
1370
            person=user, project=self
1371
        )
1372
        m.accept()
1373

    
1374
    def remove_member(self, user):
1375
        """
1376
        Raises:
1377
            django.exceptions.PermissionDenied
1378
            astakos.im.models.AstakosUser.DoesNotExist
1379
            astakos.im.models.ProjectMembership.DoesNotExist
1380
        """
1381
        if isinstance(user, int):
1382
            user = AstakosUser.objects.get(user=user)
1383

    
1384
        m = ProjectMembership.objects.get(person=user, project=self)
1385
        m.remove()
1386

    
1387
    def set_termination_start_date(self):
1388
        self.termination_start_date = datetime.now()
1389
        self.terminaton_date = None
1390
        self.save()
1391

    
1392
    def set_termination_date(self):
1393
        self.termination_start_date = None
1394
        self.termination_date = datetime.now()
1395
        self.save()
1396

    
1397

    
1398
class ProjectMembership(models.Model):
1399

    
1400
    person              =   models.ForeignKey(AstakosUser)
1401
    request_date        =   models.DateField(default=datetime.now())
1402
    project             =   models.ForeignKey(Project)
1403

    
1404
    state               =   models.IntegerField(default=0)
1405
    application         =   models.ForeignKey(
1406
                                ProjectApplication,
1407
                                null=True,
1408
                                related_name='memberships')
1409
    pending_application =   models.ForeignKey(
1410
                                ProjectApplication,
1411
                                null=True,
1412
                                related_name='pending_memebrships')
1413
    pending_serial      =   models.BigIntegerField(null=True, db_index=True)
1414

    
1415
    acceptance_date     =   models.DateField(null=True, db_index=True)
1416
    leave_request_date  =   models.DateField(null=True)
1417

    
1418
    objects     =   ForUpdateManager()
1419

    
1420
    REQUESTED   =   0
1421
    PENDING     =   1
1422
    ACCEPTED    =   2
1423
    REMOVING    =   3
1424
    REMOVED     =   4
1425

    
1426
    class Meta:
1427
        unique_together = ("person", "project")
1428
        #index_together = [["project", "state"]]
1429

    
1430
    def __str__(self):
1431
        return _("<'%s' membership in project '%s'>") % (
1432
                self.person.username, self.project.application)
1433

    
1434
    __repr__ = __str__
1435

    
1436
    def __init__(self, *args, **kwargs):
1437
        self.state = self.REQUESTED
1438
        super(ProjectMembership, self).__init__(*args, **kwargs)
1439

    
1440
    def _set_history_item(self, reason, date=None):
1441
        if isinstance(reason, basestring):
1442
            reason = ProjectMembershipHistory.reasons.get(reason, -1)
1443

    
1444
        history_item = ProjectMembershipHistory(
1445
                            serial=self.id,
1446
                            person=self.person,
1447
                            project=self.project,
1448
                            date=date or datetime.now(),
1449
                            reason=reason)
1450
        history_item.save()
1451
        serial = history_item.id
1452

    
1453
    def accept(self):
1454
        state = self.state
1455
        if state != self.REQUESTED:
1456
            m = _("%s: attempt to accept in state [%s]") % (self, state)
1457
            raise AssertionError(m)
1458

    
1459
        now = datetime.now()
1460
        self.acceptance_date = now
1461
        self._set_history_item(reason='ACCEPT', date=now)
1462
        self.state = self.PENDING
1463
        self.save()
1464

    
1465
    def remove(self):
1466
        state = self.state
1467
        if state != self.ACCEPTED:
1468
            m = _("%s: attempt to remove in state '%s'") % (self, state)
1469
            raise AssertionError(m)
1470

    
1471
        self._set_history_item(reason='REMOVE')
1472
        self.state = self.REMOVING
1473
        self.save()
1474

    
1475
    def reject(self):
1476
        state = self.state
1477
        if state != self.REQUESTED:
1478
            m = _("%s: attempt to remove in state '%s'") % (self, state)
1479
            raise AssertionError(m)
1480

    
1481
        # rejected requests don't need sync,
1482
        # because they were never effected
1483
        self._set_history_item(reason='REJECT')
1484
        self.delete()
1485

    
1486
    def get_diff_quotas(self, sub_list=None, add_list=None, remove=False):
1487
        if sub_list is None:
1488
            sub_list = []
1489

    
1490
        if add_list is None:
1491
            add_list = []
1492

    
1493
        sub_append = sub_list.append
1494
        add_append = add_list.append
1495
        holder = self.person.uuid
1496

    
1497
        synced_application = self.application
1498
        if synced_application is not None:
1499
            cur_grants = synced_application.projectresourcegrant_set.all()
1500
            for grant in cur_grants:
1501
                sub_append(QuotaLimits(
1502
                               holder       = holder,
1503
                               resource     = str(grant.resource),
1504
                               capacity     = grant.member_capacity,
1505
                               import_limit = grant.member_import_limit,
1506
                               export_limit = grant.member_export_limit))
1507

    
1508
        if not remove:
1509
            new_grants = self.pending_application.projectresourcegrant_set.all()
1510
            for new_grant in new_grants:
1511
                add_append(QuotaLimits(
1512
                               holder       = holder,
1513
                               resource     = str(new_grant.resource),
1514
                               capacity     = new_grant.member_capacity,
1515
                               import_limit = new_grant.member_import_limit,
1516
                               export_limit = new_grant.member_export_limit))
1517

    
1518
        return (sub_list, add_list)
1519

    
1520
    def set_sync(self):
1521
        state = self.state
1522
        if state == self.PENDING:
1523
            pending_application = self.pending_application
1524
            if pending_application is None:
1525
                m = _("%s: attempt to sync an empty pending application") % (
1526
                    self, state)
1527
                raise AssertionError(m)
1528
            self.application = pending_application
1529
            self.pending_application = None
1530
            self.pending_serial = None
1531

    
1532
            # project.application may have changed in the meantime,
1533
            # in which case we stay PENDING;
1534
            # we are safe to check due to select_for_update
1535
            if self.application == self.project.application:
1536
                self.state = self.ACCEPTED
1537
            self.save()
1538
        elif state == self.REMOVING:
1539
            self.delete()
1540
        else:
1541
            m = _("%s: attempt to sync in state '%s'") % (self, state)
1542
            raise AssertionError(m)
1543

    
1544
    def reset_sync(self):
1545
        state = self.state
1546
        if state in [self.PENDING, self.REMOVING]:
1547
            self.pending_application = None
1548
            self.pending_serial = None
1549
            self.save()
1550
        else:
1551
            m = _("%s: attempt to reset sync in state '%s'") % (self, state)
1552
            raise AssertionError(m)
1553

    
1554
class Serial(models.Model):
1555
    serial  =   models.AutoField(primary_key=True)
1556

    
1557
def new_serial():
1558
    s = Serial.objects.create()
1559
    serial = s.serial
1560
    s.delete()
1561
    return serial
1562

    
1563
def sync_finish_serials(serials_to_ack=None):
1564
    if serials_to_ack is None:
1565
        serials_to_ack = qh_query_serials([])
1566

    
1567
    serials_to_ack = set(serials_to_ack)
1568
    sfu = ProjectMembership.objects.select_for_update()
1569
    memberships = list(sfu.filter(pending_serial__isnull=False))
1570

    
1571
    if memberships:
1572
        for membership in memberships:
1573
            serial = membership.pending_serial
1574
            # just make sure the project row is selected for update
1575
            project = membership.project
1576
            if serial in serials_to_ack:
1577
                membership.set_sync()
1578
            else:
1579
                membership.reset_sync()
1580

    
1581
        transaction.commit()
1582

    
1583
    qh_ack_serials(list(serials_to_ack))
1584
    return len(memberships)
1585

    
1586
def sync_projects():
1587
    sync_finish_serials()
1588

    
1589
    PENDING = ProjectMembership.PENDING
1590
    REMOVING = ProjectMembership.REMOVING
1591
    objects = ProjectMembership.objects.select_for_update()
1592

    
1593
    sub_quota, add_quota = [], []
1594

    
1595
    serial = new_serial()
1596

    
1597
    pending = objects.filter(state=PENDING)
1598
    for membership in pending:
1599

    
1600
        if membership.pending_application:
1601
            m = "%s: impossible: pending_application is not None (%s)" % (
1602
                membership, membership.pending_application)
1603
            raise AssertionError(m)
1604
        if membership.pending_serial:
1605
            m = "%s: impossible: pending_serial is not None (%s)" % (
1606
                membership, membership.pending_serial)
1607
            raise AssertionError(m)
1608

    
1609
        membership.pending_application = membership.project.application
1610
        membership.pending_serial = serial
1611
        membership.get_diff_quotas(sub_quota, add_quota)
1612
        membership.save()
1613

    
1614
    removing = objects.filter(state=REMOVING)
1615
    for membership in removing:
1616

    
1617
        if membership.pending_application:
1618
            m = ("%s: impossible: removing pending_application is not None (%s)"
1619
                % (membership, membership.pending_application))
1620
            raise AssertionError(m)
1621
        if membership.pending_serial:
1622
            m = "%s: impossible: pending_serial is not None (%s)" % (
1623
                membership, membership.pending_serial)
1624
            raise AssertionError(m)
1625

    
1626
        membership.pending_serial = serial
1627
        membership.get_diff_quotas(sub_quota, add_quota, remove=True)
1628
        membership.save()
1629

    
1630
    transaction.commit()
1631
    # ProjectApplication.approve() unblocks here
1632
    # and can set PENDING an already PENDING membership
1633
    # which has been scheduled to sync with the old project.application
1634
    # Need to check in ProjectMembership.set_sync()
1635

    
1636
    r = qh_add_quota(serial, sub_quota, add_quota)
1637
    if r:
1638
        m = "cannot sync serial: %d" % serial
1639
        raise RuntimeError(m)
1640

    
1641
    sync_finish_serials([serial])
1642

    
1643

    
1644
def trigger_sync(retries=3, retry_wait=1.0):
1645
    cursor = connection.cursor()
1646
    locked = True
1647
    try:
1648
        while 1:
1649
            cursor.execute("SELECT pg_try_advisory_lock(1)")
1650
            r = cursor.fetchone()
1651
            if r is None:
1652
                m = "Impossible"
1653
                raise AssertionError(m)
1654
            locked = r[0]
1655
            if locked:
1656
                break
1657

    
1658
            retries -= 1
1659
            if retries <= 0:
1660
                return False
1661
            sleep(retry_wait)
1662

    
1663
        transaction.commit()
1664
        sync_projects()
1665
        return True
1666

    
1667
    finally:
1668
        if locked:
1669
            cursor.execute("SELECT pg_advisory_unlock(1)")
1670
            cursor.fetchall()
1671

    
1672

    
1673
class ProjectMembershipHistory(models.Model):
1674
    reasons_list    =   ['ACCEPT', 'REJECT', 'REMOVE']
1675
    reasons         =   dict((k, v) for v, k in enumerate(reasons_list))
1676

    
1677
    person  =   models.ForeignKey(AstakosUser)
1678
    project =   models.ForeignKey(Project)
1679
    date    =   models.DateField(default=datetime.now)
1680
    reason  =   models.IntegerField()
1681
    serial  =   models.BigIntegerField()
1682

    
1683
### SIGNALS ###
1684
################
1685

    
1686
def create_astakos_user(u):
1687
    try:
1688
        AstakosUser.objects.get(user_ptr=u.pk)
1689
    except AstakosUser.DoesNotExist:
1690
        extended_user = AstakosUser(user_ptr_id=u.pk)
1691
        extended_user.__dict__.update(u.__dict__)
1692
        extended_user.save()
1693
        if not extended_user.has_auth_provider('local'):
1694
            extended_user.add_auth_provider('local')
1695
    except BaseException, e:
1696
        logger.exception(e)
1697

    
1698

    
1699
def fix_superusers(sender, **kwargs):
1700
    # Associate superusers with AstakosUser
1701
    admins = User.objects.filter(is_superuser=True)
1702
    for u in admins:
1703
        create_astakos_user(u)
1704
post_syncdb.connect(fix_superusers)
1705

    
1706

    
1707
def user_post_save(sender, instance, created, **kwargs):
1708
    if not created:
1709
        return
1710
    create_astakos_user(instance)
1711
post_save.connect(user_post_save, sender=User)
1712

    
1713
def astakosuser_post_save(sender, instance, created, **kwargs):
1714
    if not created:
1715
        return
1716
    # TODO handle socket.error & IOError
1717
    register_users((instance,))
1718
post_save.connect(astakosuser_post_save, sender=AstakosUser)
1719

    
1720

    
1721
def resource_post_save(sender, instance, created, **kwargs):
1722
    if not created:
1723
        return
1724
    register_resources((instance,))
1725
post_save.connect(resource_post_save, sender=Resource)
1726

    
1727

    
1728
def renew_token(sender, instance, **kwargs):
1729
    if not instance.auth_token:
1730
        instance.renew_token()
1731
pre_save.connect(renew_token, sender=AstakosUser)
1732
pre_save.connect(renew_token, sender=Service)
1733