Revision 65360c65 snf-astakos-app/astakos/im/models.py

b/snf-astakos-app/astakos/im/models.py
41 41
from urlparse import urlparse
42 42
from urllib import quote
43 43
from random import randint
44
from collections import defaultdict
44
from collections import defaultdict, namedtuple
45 45

  
46 46
from django.db import models, IntegrityError
47 47
from django.contrib.auth.models import User, UserManager, Group, Permission
......
1101 1101
    _closed_leave = closed
1102 1102
    return closeds
1103 1103

  
1104

  
1105
### PROJECTS ###
1106
################
1107

  
1108

  
1109
class SyncedModel(models.Model):
1110

  
1111
    new_state       = models.BigIntegerField()
1112
    synced_state    = models.BigIntegerField()
1113
    STATUS_SYNCED   = 0
1114
    STATUS_PENDING  = 1
1115
    sync_status     = models.IntegerField(db_index=True)
1116

  
1117
    class Meta:
1118
        abstract = True
1119

  
1120
    class NotSynced(Exception):
1121
        pass
1122

  
1123
    def sync_init_state(self, state):
1124
        self.synced_state = state
1125
        self.new_state = state
1126
        self.sync_status = self.STATUS_SYNCED
1127

  
1128
    def sync_get_status(self):
1129
        return self.sync_status
1130

  
1131
    def sync_set_status(self):
1132
        if self.new_state != self.synced_state:
1133
            self.sync_status = self.STATUS_PENDING
1134
        else:
1135
            self.sync_status = self.STATUS_SYNCED
1136

  
1137
    def sync_set_synced(self):
1138
        self.synced_state = self.new_state
1139
        self.sync_status = self.STATUS_SYNCED
1140

  
1141
    def sync_get_synced_state(self):
1142
        return self.synced_state
1143

  
1144
    def sync_set_new_state(self, new_state):
1145
        self.new_state = new_state
1146
        self.sync_set_status()
1147

  
1148
    def sync_get_new_state(self):
1149
        return self.new_state
1150

  
1151
    def sync_set_synced_state(self, synced_state):
1152
        self.synced_state = synced_state
1153
        self.sync_set_status()
1154

  
1155
    def sync_get_pending_objects(self):
1156
        return self.objects.filter(sync_status=self.STATUS_PENDING)
1157

  
1158
    def sync_get_synced_objects(self):
1159
        return self.objects.filter(sync_status=self.STATUS_SYNCED)
1160

  
1161
    def sync_verify_get_synced_state(self):
1162
        status = self.sync_get_status()
1163
        state = self.sync_get_synced_state()
1164
        verified = (status == self.STATUS_SYNCED)
1165
        return state, verified
1166

  
1167

  
1104 1168
class ProjectResourceGrant(models.Model):
1105
    objects = ExtendedManager()
1106
    member_limit = models.BigIntegerField(null=True)
1107
    project_limit = models.BigIntegerField(null=True)
1169

  
1108 1170
    resource = models.ForeignKey(Resource)
1109 1171
    project_application = models.ForeignKey(ProjectApplication, blank=True)
1172
    project_capacity     = models.BigIntegerField(null=True)
1173
    project_import_limit = models.BigIntegerField(null=True)
1174
    project_export_limit = models.BigIntegerField(null=True)
1175
    member_capacity      = models.BigIntegerField(null=True)
1176
    member_import_limit  = models.BigIntegerField(null=True)
1177
    member_export_limit  = models.BigIntegerField(null=True)
1178

  
1179
    objects = ExtendedManager()
1110 1180

  
1111 1181
    class Meta:
1112 1182
        unique_together = ("resource", "project_application")
......
1114 1184

  
1115 1185
class ProjectApplication(models.Model):
1116 1186
    states_list = [PENDING, APPROVED, REPLACED, UNKNOWN]
1117
    states = dict((k, v) for k, v in enumerate(states_list))
1187
    states = dict((k, v) for v, k in enumerate(states_list))
1118 1188

  
1119 1189
    applicant = models.ForeignKey(
1120 1190
        AstakosUser,
......
1241 1311

  
1242 1312
        new_project_name = self.definition.name
1243 1313
        if self.state != PENDING:
1244
            m = _("cannot approve: project '%s' in state '%s'"
1245
                % (new_project_name, self.state))
1314
            m = _("cannot approve: project '%s' in state '%s'") % (
1315
                    new_project_name, self.state)
1246 1316
            raise PermissionDenied(m) # invalid argument
1247 1317

  
1248 1318
        now = datetime.now()
......
1252 1322
                conflicting_project = Project.objects.get(name=new_project_name)
1253 1323
                if conflicting_project.is_alive:
1254 1324
                    m = _("cannot approve: project with name '%s' "
1255
                          "already exists (serial: %s)"
1256
                          % (new_project_name, conflicting_project.id))
1325
                          "already exists (serial: %s)") % (
1326
                            new_project_name, conflicting_project.id)
1257 1327
                    raise PermissionDenied(m) # invalid argument
1258 1328
            except Project.DoesNotExist:
1259 1329
                pass
......
1292 1362
            logger.error(e.messages)
1293 1363

  
1294 1364

  
1295
class Project(models.Model):
1365
class Project(SyncedModel):
1296 1366
    application                 =   models.OneToOneField(
1297 1367
                                            ProjectApplication,
1298 1368
                                            related_name='project',
......
1306 1376
                                            AstakosUser,
1307 1377
                                            through='ProjectMembership')
1308 1378

  
1309
    current_membership_serial   =   models.BigIntegerField()
1310
    synced_membership_serial    =   models.BigIntegerField()
1311

  
1312 1379
    termination_start_date      =   models.DateTimeField(null=True)
1313 1380
    termination_date            =   models.DateTimeField(null=True)
1314 1381

  
......
1380 1447
        return False
1381 1448
    
1382 1449
    @property
1383
    def is_synchronized(self):
1384
        return self.last_application_approved == self.application and \
1385
            not self.membership_dirty and \
1386
            (not self.termination_start_date or termination_date)
1387
    
1388
    @property
1389 1450
    def approved_members(self):
1390 1451
        return [m.person for m in self.projectmembership_set.filter(~Q(acceptance_date=None))]
1391
        
1452

  
1392 1453
    def sync(self, specific_members=()):
1393 1454
        if self.is_synchronized:
1394 1455
            return
......
1504 1565
            logger.error(e.messages)
1505 1566

  
1506 1567

  
1507
class ProjectMembership(models.Model):
1568
QuotaLimits = namedtuple('QuotaLimits', ('holder',
1569
                                         'capacity',
1570
                                         'import_limit',
1571
                                         'export_limit'))
1572

  
1573

  
1574

  
1575
class ExclusiveOrRaise(object):
1576
    """Context Manager to exclusively execute a critical code section.
1577
       The exclusion must be global.
1578
       (IPC semaphores will not protect across OS,
1579
        DB locks will if it's the same DB)
1580
    """
1581

  
1582
    class Busy(Exception):
1583
        pass
1584

  
1585
    def __init__(self, locked=False):
1586
        init = 0 if locked else 1
1587
        from multiprocess import Semaphore
1588
        self._sema = Semaphore(init)
1589

  
1590
    def enter(self):
1591
        acquired = self._sema.acquire(False)
1592
        if not acquired:
1593
            raise self.Busy()
1594

  
1595
    def leave(self):
1596
        self._sema.release()
1597

  
1598
    def __enter__(self):
1599
        self.enter()
1600
        return self
1601

  
1602
    def __exit__(self, exc_type, exc_value, exc_traceback):
1603
        self.leave()
1604

  
1605

  
1606
exclusive_or_raise = ExclusiveOrRaise(locked=False)
1607

  
1608

  
1609
class ProjectMembership(SyncedModel):
1508 1610
    person = models.ForeignKey(AstakosUser)
1509 1611
    project = models.ForeignKey(Project)
1510 1612
    request_date = models.DateField(default=datetime.now())
......
1512 1614
    acceptance_date = models.DateField(null=True, db_index=True)
1513 1615
    leave_request_date = models.DateField(null=True)
1514 1616

  
1617
    REQUESTED   =   0
1618
    ACCEPTED    =   1
1619
    REMOVED     =   2
1620
    REJECTED    =   3   # never seen, because .delete()
1621

  
1515 1622
    class Meta:
1516 1623
        unique_together = ("person", "project")
1517 1624

  
1625
    def __str__(self):
1626
        return _("<'%s' membership in project '%s'>") % (
1627
                self.person.username, self.project.application)
1628

  
1629
    __repr__ = __str__
1630

  
1631
    def __init__(self, *args, **kwargs):
1632
        self.sync_init_state(self.REQUEST)
1633
        super(ProjectMembership, self).__init__(*args, **kwargs)
1634

  
1518 1635
    def _set_history_item(self, reason, date=None):
1519 1636
        if isinstance(reason, basestring):
1520 1637
            reason = ProjectMembershipHistory.reasons.get(reason, -1)
......
1529 1646
        serial = history_item.id
1530 1647

  
1531 1648
    def accept(self):
1532
        if not self.acceptance_date:
1533
            now = datetime.now()
1534
            self.acceptance_date = now
1535
            serial = self._set_history_item(reason='ACCEPT', date=now)
1536
            self.project.current_membership_serial = serial
1537
            self.save()
1649
        state, verified = self.sync_verify_get_synced_state()
1650
        if not verified:
1651
            new_state = self.sync_get_new_state()
1652
            m = _("%s: cannot accept: not synched (%s -> %s)") % (
1653
                    self, state, new_state)
1654
            raise self.NotSynced(m)
1538 1655

  
1539
    def remove(self):
1540
        serial = self._set_history_item(reason='REMOVE')
1541
        self.project.current_membership_serial = serial
1542
        self.delete()
1656
        if state != self.REQUESTED:
1657
            m = _("%s: attempt to accept in state '%s'") % (self, state)
1658
            raise AssertionError(m)
1543 1659

  
1544
    def reject(self):
1545
        self._set_history_item(reason='REJECT')
1546
        self.delete()
1660
        now = datetime.now()
1661
        self.acceptance_date = now
1662
        self._set_history_item(reason='ACCEPT', date=now)
1663
        self.sync_set_new_state(self.ACCEPTED)
1664
        self.save()
1547 1665

  
1666
    def remove(self):
1667
        state, verified = self.sync_verify_get_synced_state()
1668
        if not verified:
1669
            new_state = self.sync_get_new_state()
1670
            m = _("%s: cannot remove: not synched (%s -> %s)") % (
1671
                    self, state, new_state)
1672
            raise self.NotSynced(m)
1548 1673

  
1549
    ### Views to be moved to views ###
1674
        if state != self.ACCEPTED:
1675
            m = _("%s: attempt to remove in state '%s'") % (self, state)
1676
            raise AssertionError(m)
1550 1677

  
1551
    def accept_view(self, delete_on_failure=False, request_user=None):
1552
        """
1553
            Raises:
1554
                django.exception.PermissionDenied
1555
        """
1556
        try:
1557
            if request_user and \
1558
                (not self.project.current_application.owner == request_user and \
1559
                    not request_user.is_superuser):
1560
                raise PermissionDenied(_(astakos_messages.NOT_ALLOWED))
1561
            if not self.project.is_alive:
1562
                raise PermissionDenied(_(astakos_messages.NOT_ALIVE_PROJECT) % self.project.__dict__)
1563
            if len(self.project.approved_members) + 1 > self.project.definition.limit_on_members_number:
1564
                raise PermissionDenied(_(astakos_messages.MEMBER_NUMBER_LIMIT_REACHED))
1565
        except PermissionDenied, e:
1566
            if delete_on_failure:
1567
                self.delete()
1568
            raise
1569
        if self.acceptance_date:
1570
            return
1571
        self.acceptance_date = datetime.now()
1678
        serial = self._set_history_item(reason='REMOVE')
1679
        self.sync_set_new_state(self.REMOVED)
1572 1680
        self.save()
1573
        self.sync()
1574 1681

  
1575
        try:
1576
            notification = build_notification(
1577
                settings.SERVER_EMAIL,
1578
                [self.person.email],
1579
                _(PROJECT_MEMBERSHIP_CHANGE_SUBJECT) % self.project.definition.__dict__,
1580
                template='im/projects/project_membership_change_notification.txt',
1581
                dictionary={'object':self.project.current_application, 'action':'accepted'}
1582
            ).send()
1583
        except NotificationError, e:
1584
            logger.error(e.messages)
1585

  
1586
    def reject_view(self, request_user=None):
1587
        """
1588
            Raises:
1589
                django.exception.PermissionDenied
1590
        """
1591
        if request_user and \
1592
            (not self.project.current_application.owner == request_user and \
1593
                not request_user.is_superuser):
1594
            raise PermissionDenied(_(astakos_messages.NOT_ALLOWED))
1595
        if not self.project.is_alive:
1596
            raise PermissionDenied(_(astakos_messages.NOT_ALIVE_PROJECT) % project.__dict__)
1597
        history_item = ProjectMembershipHistory(
1598
            person=self.person,
1599
            project=self.project,
1600
            request_date=self.request_date,
1601
            rejection_date=datetime.now()
1602
        )
1682
    def reject(self):
1683
        state, verified = self.sync_verify_get_synced_state()
1684
        if not verified:
1685
            new_state = self.sync_get_new_state()
1686
            m = _("%s: cannot reject: not synched (%s -> %s)") % (
1687
                    self, state, new_state))
1688
            raise self.NotSynced(m)
1689

  
1690
        if state != self.REQUESTED:
1691
            m = _("%s: attempt to remove in state '%s'") % (self, state)
1692
            raise AssertionError(m)
1693

  
1694
        # rejected requests don't need sync,
1695
        # because they were never effected
1696
        self._set_history_item(reason='REJECT')
1603 1697
        self.delete()
1604
        history_item.save()
1605

  
1606
        try:
1607
            notification = build_notification(
1608
                settings.SERVER_EMAIL,
1609
                [self.person.email],
1610
                _(PROJECT_MEMBERSHIP_CHANGE_SUBJECT) % self.project.definition.__dict__,
1611
                template='im/projects/project_membership_change_notification.txt',
1612
                dictionary={'object':self.project.current_application, 'action':'rejected'}
1613
            ).send()
1614
        except NotificationError, e:
1615
            logger.error(e.messages)
1616 1698

  
1617
    def remove_view(self, request_user=None):
1618
        """
1619
            Raises:
1620
                django.exception.PermissionDenied
1621
        """
1622
        if request_user and \
1623
            (not self.project.current_application.owner == request_user and \
1624
                not request_user.is_superuser):
1625
            raise PermissionDenied(_(astakos_messages.NOT_ALLOWED))
1626
        if not self.project.is_alive:
1627
            raise PermissionDenied(_(astakos_messages.NOT_ALIVE_PROJECT) % self.project.__dict__)
1628
        history_item = ProjectMembershipHistory(
1629
            id=self.id,
1630
            person=self.person,
1631
            project=self.project,
1632
            request_date=self.request_date,
1633
            removal_date=datetime.now()
1634
        )
1635
        self.delete()
1636
        history_item.save()
1637
        self.sync()
1699
    def get_quotas(self, limits_list=None, factor=1):
1700
        holder = self.person.username
1701
        if limits_list is None:
1702
            limits_list = []
1703
        append = limits_list.append
1704
        all_grants = self.project.application.resource_grants.all()
1705
        for grant in all_grants:
1706
            append(QuotaLimits(holder       = holder,
1707
                               resource     = grant.resource.name,
1708
                               capacity     = factor * grant.member_capacity,
1709
                               import_limit = factor * grant.member_import_limit,
1710
                               export_limit = factor * grant.member_export_limit))
1711
        return limits_list
1712

  
1713
    def do_sync(self):
1714
        state = self.sync_get_synced_state()
1715
        new_state = self.sync_get_new_state()
1716

  
1717
        if state == self.REQUESTED and new_state == self.ACCEPTED:
1718
            factor = 1
1719
        elif state == self.ACCEPTED and new_state == self.REMOVED:
1720
            factor = -1
1721
        else:
1722
            m = _("%s: sync: called on invalid state ('%s' -> '%s')") % (
1723
                    self, state, new_state)
1724
            raise AssertionError(m)
1638 1725

  
1726
        quotas = self.get_quotas(factor=factor)
1639 1727
        try:
1640
            notification = build_notification(
1641
                settings.SERVER_EMAIL,
1642
                [self.person.email],
1643
                _(PROJECT_MEMBERSHIP_CHANGE_SUBJECT) % self.project.definition.__dict__,
1644
                template='im/projects/project_membership_change_notification.txt',
1645
                dictionary={'object':self.project.current_application, 'action':'removed'}
1646
            ).send()
1647
        except NotificationError, e:
1648
            logger.error(e.messages)
1649
    
1650
    def leave_view(self):
1651
        leave_policy = self.project.current_application.definition.member_leave_policy
1652
        if leave_policy == get_auto_accept_leave():
1653
            self.remove()
1728
            failure = add_quotas(quotas)
1729
            if failure:
1730
                m = "%s: sync: add_quotas failed" % (self,)
1731
                raise RuntimeError(m)
1732
        except Exception:
1733
            raise
1654 1734
        else:
1655
            self.leave_request_date = datetime.now()
1656
            self.save()
1735
            self.sync_set_synced()
1736

  
1737
        if new_state == self.REMOVED:
1738
            self.delete()
1657 1739

  
1658 1740
    def sync(self):
1659
        # set membership_dirty flag
1660
        self.project.membership_dirty = True
1661
        self.project.save()
1662
        
1663
        rejected = self.project.sync(specific_members=[self.person])
1664
        if not rejected:
1665
            # if syncing was successful unset membership_dirty flag
1666
            self.membership_dirty = False
1667
            self.project.save()
1741
        with exclusive_or_raise:
1742
            self.do_sync()
1668 1743

  
1669 1744

  
1670 1745
class ProjectMembershipHistory(models.Model):

Also available in: Unified diff