Commit 3ffacf08 authored by Georgios D. Tsoukalas's avatar Georgios D. Tsoukalas
Browse files

wip sync with per membership state

parent 2c3779e5
...@@ -41,7 +41,7 @@ from base64 import b64encode ...@@ -41,7 +41,7 @@ from base64 import b64encode
from urlparse import urlparse from urlparse import urlparse
from urllib import quote from urllib import quote
from random import randint from random import randint
from collections import defaultdict from collections import defaultdict, namedtuple
from django.db import models, IntegrityError from django.db import models, IntegrityError
from django.contrib.auth.models import User, UserManager, Group, Permission from django.contrib.auth.models import User, UserManager, Group, Permission
...@@ -1101,12 +1101,82 @@ def get_closed_leave(): ...@@ -1101,12 +1101,82 @@ def get_closed_leave():
_closed_leave = closed _closed_leave = closed
return closeds return closeds
### PROJECTS ###
################
class SyncedModel(models.Model):
new_state = models.BigIntegerField()
synced_state = models.BigIntegerField()
STATUS_SYNCED = 0
STATUS_PENDING = 1
sync_status = models.IntegerField(db_index=True)
class Meta:
abstract = True
class NotSynced(Exception):
pass
def sync_init_state(self, state):
self.synced_state = state
self.new_state = state
self.sync_status = self.STATUS_SYNCED
def sync_get_status(self):
return self.sync_status
def sync_set_status(self):
if self.new_state != self.synced_state:
self.sync_status = self.STATUS_PENDING
else:
self.sync_status = self.STATUS_SYNCED
def sync_set_synced(self):
self.synced_state = self.new_state
self.sync_status = self.STATUS_SYNCED
def sync_get_synced_state(self):
return self.synced_state
def sync_set_new_state(self, new_state):
self.new_state = new_state
self.sync_set_status()
def sync_get_new_state(self):
return self.new_state
def sync_set_synced_state(self, synced_state):
self.synced_state = synced_state
self.sync_set_status()
def sync_get_pending_objects(self):
return self.objects.filter(sync_status=self.STATUS_PENDING)
def sync_get_synced_objects(self):
return self.objects.filter(sync_status=self.STATUS_SYNCED)
def sync_verify_get_synced_state(self):
status = self.sync_get_status()
state = self.sync_get_synced_state()
verified = (status == self.STATUS_SYNCED)
return state, verified
class ProjectResourceGrant(models.Model): class ProjectResourceGrant(models.Model):
objects = ExtendedManager()
member_limit = models.BigIntegerField(null=True)
project_limit = models.BigIntegerField(null=True)
resource = models.ForeignKey(Resource) resource = models.ForeignKey(Resource)
project_application = models.ForeignKey(ProjectApplication, blank=True) project_application = models.ForeignKey(ProjectApplication, blank=True)
project_capacity = models.BigIntegerField(null=True)
project_import_limit = models.BigIntegerField(null=True)
project_export_limit = models.BigIntegerField(null=True)
member_capacity = models.BigIntegerField(null=True)
member_import_limit = models.BigIntegerField(null=True)
member_export_limit = models.BigIntegerField(null=True)
objects = ExtendedManager()
class Meta: class Meta:
unique_together = ("resource", "project_application") unique_together = ("resource", "project_application")
...@@ -1114,7 +1184,7 @@ class ProjectResourceGrant(models.Model): ...@@ -1114,7 +1184,7 @@ class ProjectResourceGrant(models.Model):
class ProjectApplication(models.Model): class ProjectApplication(models.Model):
states_list = [PENDING, APPROVED, REPLACED, UNKNOWN] states_list = [PENDING, APPROVED, REPLACED, UNKNOWN]
states = dict((k, v) for k, v in enumerate(states_list)) states = dict((k, v) for v, k in enumerate(states_list))
applicant = models.ForeignKey( applicant = models.ForeignKey(
AstakosUser, AstakosUser,
...@@ -1241,8 +1311,8 @@ class ProjectApplication(models.Model): ...@@ -1241,8 +1311,8 @@ class ProjectApplication(models.Model):
new_project_name = self.definition.name new_project_name = self.definition.name
if self.state != PENDING: if self.state != PENDING:
m = _("cannot approve: project '%s' in state '%s'" m = _("cannot approve: project '%s' in state '%s'") % (
% (new_project_name, self.state)) new_project_name, self.state)
raise PermissionDenied(m) # invalid argument raise PermissionDenied(m) # invalid argument
now = datetime.now() now = datetime.now()
...@@ -1252,8 +1322,8 @@ class ProjectApplication(models.Model): ...@@ -1252,8 +1322,8 @@ class ProjectApplication(models.Model):
conflicting_project = Project.objects.get(name=new_project_name) conflicting_project = Project.objects.get(name=new_project_name)
if conflicting_project.is_alive: if conflicting_project.is_alive:
m = _("cannot approve: project with name '%s' " m = _("cannot approve: project with name '%s' "
"already exists (serial: %s)" "already exists (serial: %s)") % (
% (new_project_name, conflicting_project.id)) new_project_name, conflicting_project.id)
raise PermissionDenied(m) # invalid argument raise PermissionDenied(m) # invalid argument
except Project.DoesNotExist: except Project.DoesNotExist:
pass pass
...@@ -1292,7 +1362,7 @@ class ProjectApplication(models.Model): ...@@ -1292,7 +1362,7 @@ class ProjectApplication(models.Model):
logger.error(e.messages) logger.error(e.messages)
class Project(models.Model): class Project(SyncedModel):
application = models.OneToOneField( application = models.OneToOneField(
ProjectApplication, ProjectApplication,
related_name='project', related_name='project',
...@@ -1306,9 +1376,6 @@ class Project(models.Model): ...@@ -1306,9 +1376,6 @@ class Project(models.Model):
AstakosUser, AstakosUser,
through='ProjectMembership') through='ProjectMembership')
current_membership_serial = models.BigIntegerField()
synced_membership_serial = models.BigIntegerField()
termination_start_date = models.DateTimeField(null=True) termination_start_date = models.DateTimeField(null=True)
termination_date = models.DateTimeField(null=True) termination_date = models.DateTimeField(null=True)
...@@ -1379,16 +1446,10 @@ class Project(models.Model): ...@@ -1379,16 +1446,10 @@ class Project(models.Model):
return True return True
return False return False
@property
def is_synchronized(self):
return self.last_application_approved == self.application and \
not self.membership_dirty and \
(not self.termination_start_date or termination_date)
@property @property
def approved_members(self): def approved_members(self):
return [m.person for m in self.projectmembership_set.filter(~Q(acceptance_date=None))] return [m.person for m in self.projectmembership_set.filter(~Q(acceptance_date=None))]
def sync(self, specific_members=()): def sync(self, specific_members=()):
if self.is_synchronized: if self.is_synchronized:
return return
...@@ -1504,7 +1565,48 @@ class Project(models.Model): ...@@ -1504,7 +1565,48 @@ class Project(models.Model):
logger.error(e.messages) logger.error(e.messages)
class ProjectMembership(models.Model): QuotaLimits = namedtuple('QuotaLimits', ('holder',
'capacity',
'import_limit',
'export_limit'))
class ExclusiveOrRaise(object):
"""Context Manager to exclusively execute a critical code section.
The exclusion must be global.
(IPC semaphores will not protect across OS,
DB locks will if it's the same DB)
"""
class Busy(Exception):
pass
def __init__(self, locked=False):
init = 0 if locked else 1
from multiprocess import Semaphore
self._sema = Semaphore(init)
def enter(self):
acquired = self._sema.acquire(False)
if not acquired:
raise self.Busy()
def leave(self):
self._sema.release()
def __enter__(self):
self.enter()
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
self.leave()
exclusive_or_raise = ExclusiveOrRaise(locked=False)
class ProjectMembership(SyncedModel):
person = models.ForeignKey(AstakosUser) person = models.ForeignKey(AstakosUser)
project = models.ForeignKey(Project) project = models.ForeignKey(Project)
request_date = models.DateField(default=datetime.now()) request_date = models.DateField(default=datetime.now())
...@@ -1512,9 +1614,24 @@ class ProjectMembership(models.Model): ...@@ -1512,9 +1614,24 @@ class ProjectMembership(models.Model):
acceptance_date = models.DateField(null=True, db_index=True) acceptance_date = models.DateField(null=True, db_index=True)
leave_request_date = models.DateField(null=True) leave_request_date = models.DateField(null=True)
REQUESTED = 0
ACCEPTED = 1
REMOVED = 2
REJECTED = 3 # never seen, because .delete()
class Meta: class Meta:
unique_together = ("person", "project") unique_together = ("person", "project")
def __str__(self):
return _("<'%s' membership in project '%s'>") % (
self.person.username, self.project.application)
__repr__ = __str__
def __init__(self, *args, **kwargs):
self.sync_init_state(self.REQUEST)
super(ProjectMembership, self).__init__(*args, **kwargs)
def _set_history_item(self, reason, date=None): def _set_history_item(self, reason, date=None):
if isinstance(reason, basestring): if isinstance(reason, basestring):
reason = ProjectMembershipHistory.reasons.get(reason, -1) reason = ProjectMembershipHistory.reasons.get(reason, -1)
...@@ -1529,142 +1646,100 @@ class ProjectMembership(models.Model): ...@@ -1529,142 +1646,100 @@ class ProjectMembership(models.Model):
serial = history_item.id serial = history_item.id
def accept(self): def accept(self):
if not self.acceptance_date: state, verified = self.sync_verify_get_synced_state()
now = datetime.now() if not verified:
self.acceptance_date = now new_state = self.sync_get_new_state()
serial = self._set_history_item(reason='ACCEPT', date=now) m = _("%s: cannot accept: not synched (%s -> %s)") % (
self.project.current_membership_serial = serial self, state, new_state)
self.save() raise self.NotSynced(m)
def remove(self): if state != self.REQUESTED:
serial = self._set_history_item(reason='REMOVE') m = _("%s: attempt to accept in state '%s'") % (self, state)
self.project.current_membership_serial = serial raise AssertionError(m)
self.delete()
def reject(self): now = datetime.now()
self._set_history_item(reason='REJECT') self.acceptance_date = now
self.delete() self._set_history_item(reason='ACCEPT', date=now)
self.sync_set_new_state(self.ACCEPTED)
self.save()
def remove(self):
state, verified = self.sync_verify_get_synced_state()
if not verified:
new_state = self.sync_get_new_state()
m = _("%s: cannot remove: not synched (%s -> %s)") % (
self, state, new_state)
raise self.NotSynced(m)
### Views to be moved to views ### if state != self.ACCEPTED:
m = _("%s: attempt to remove in state '%s'") % (self, state)
raise AssertionError(m)
def accept_view(self, delete_on_failure=False, request_user=None): serial = self._set_history_item(reason='REMOVE')
""" self.sync_set_new_state(self.REMOVED)
Raises:
django.exception.PermissionDenied
"""
try:
if request_user and \
(not self.project.current_application.owner == request_user and \
not request_user.is_superuser):
raise PermissionDenied(_(astakos_messages.NOT_ALLOWED))
if not self.project.is_alive:
raise PermissionDenied(_(astakos_messages.NOT_ALIVE_PROJECT) % self.project.__dict__)
if len(self.project.approved_members) + 1 > self.project.definition.limit_on_members_number:
raise PermissionDenied(_(astakos_messages.MEMBER_NUMBER_LIMIT_REACHED))
except PermissionDenied, e:
if delete_on_failure:
self.delete()
raise
if self.acceptance_date:
return
self.acceptance_date = datetime.now()
self.save() self.save()
self.sync()
try: def reject(self):
notification = build_notification( state, verified = self.sync_verify_get_synced_state()
settings.SERVER_EMAIL, if not verified:
[self.person.email], new_state = self.sync_get_new_state()
_(PROJECT_MEMBERSHIP_CHANGE_SUBJECT) % self.project.definition.__dict__, m = _("%s: cannot reject: not synched (%s -> %s)") % (
template='im/projects/project_membership_change_notification.txt', self, state, new_state))
dictionary={'object':self.project.current_application, 'action':'accepted'} raise self.NotSynced(m)
).send()
except NotificationError, e: if state != self.REQUESTED:
logger.error(e.messages) m = _("%s: attempt to remove in state '%s'") % (self, state)
raise AssertionError(m)
def reject_view(self, request_user=None):
""" # rejected requests don't need sync,
Raises: # because they were never effected
django.exception.PermissionDenied self._set_history_item(reason='REJECT')
"""
if request_user and \
(not self.project.current_application.owner == request_user and \
not request_user.is_superuser):
raise PermissionDenied(_(astakos_messages.NOT_ALLOWED))
if not self.project.is_alive:
raise PermissionDenied(_(astakos_messages.NOT_ALIVE_PROJECT) % project.__dict__)
history_item = ProjectMembershipHistory(
person=self.person,
project=self.project,
request_date=self.request_date,
rejection_date=datetime.now()
)
self.delete() self.delete()
history_item.save()
try:
notification = build_notification(
settings.SERVER_EMAIL,
[self.person.email],
_(PROJECT_MEMBERSHIP_CHANGE_SUBJECT) % self.project.definition.__dict__,
template='im/projects/project_membership_change_notification.txt',
dictionary={'object':self.project.current_application, 'action':'rejected'}
).send()
except NotificationError, e:
logger.error(e.messages)
def remove_view(self, request_user=None): def get_quotas(self, limits_list=None, factor=1):
""" holder = self.person.username
Raises: if limits_list is None:
django.exception.PermissionDenied limits_list = []
""" append = limits_list.append
if request_user and \ all_grants = self.project.application.resource_grants.all()
(not self.project.current_application.owner == request_user and \ for grant in all_grants:
not request_user.is_superuser): append(QuotaLimits(holder = holder,
raise PermissionDenied(_(astakos_messages.NOT_ALLOWED)) resource = grant.resource.name,
if not self.project.is_alive: capacity = factor * grant.member_capacity,
raise PermissionDenied(_(astakos_messages.NOT_ALIVE_PROJECT) % self.project.__dict__) import_limit = factor * grant.member_import_limit,
history_item = ProjectMembershipHistory( export_limit = factor * grant.member_export_limit))
id=self.id, return limits_list
person=self.person,
project=self.project, def do_sync(self):
request_date=self.request_date, state = self.sync_get_synced_state()
removal_date=datetime.now() new_state = self.sync_get_new_state()
)
self.delete() if state == self.REQUESTED and new_state == self.ACCEPTED:
history_item.save() factor = 1
self.sync() elif state == self.ACCEPTED and new_state == self.REMOVED:
factor = -1
else:
m = _("%s: sync: called on invalid state ('%s' -> '%s')") % (
self, state, new_state)
raise AssertionError(m)
quotas = self.get_quotas(factor=factor)
try: try:
notification = build_notification( failure = add_quotas(quotas)
settings.SERVER_EMAIL, if failure:
[self.person.email], m = "%s: sync: add_quotas failed" % (self,)
_(PROJECT_MEMBERSHIP_CHANGE_SUBJECT) % self.project.definition.__dict__, raise RuntimeError(m)
template='im/projects/project_membership_change_notification.txt', except Exception:
dictionary={'object':self.project.current_application, 'action':'removed'} raise
).send()
except NotificationError, e:
logger.error(e.messages)
def leave_view(self):
leave_policy = self.project.current_application.definition.member_leave_policy
if leave_policy == get_auto_accept_leave():
self.remove()
else: else:
self.leave_request_date = datetime.now() self.sync_set_synced()
self.save()
if new_state == self.REMOVED:
self.delete()
def sync(self): def sync(self):
# set membership_dirty flag with exclusive_or_raise:
self.project.membership_dirty = True self.do_sync()
self.project.save()
rejected = self.project.sync(specific_members=[self.person])
if not rejected:
# if syncing was successful unset membership_dirty flag
self.membership_dirty = False
self.project.save()
class ProjectMembershipHistory(models.Model): class ProjectMembershipHistory(models.Model):
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment