Commit 99fba34f authored by Lukas Burgey's avatar Lukas Burgey
Browse files

Implement the questionnaire logic

parent d45e4e76
...@@ -5,3 +5,4 @@ db.cnf ...@@ -5,3 +5,4 @@ db.cnf
static static
deployment deployment
deploy deploy
runtest
...@@ -7,6 +7,7 @@ from django.contrib.sessions.models import Session ...@@ -7,6 +7,7 @@ from django.contrib.sessions.models import Session
from ...models import User, RabbitMQInstance from ...models import User, RabbitMQInstance
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
CLIENT_DEBUGGING = False
ALLOW = HttpResponse('allow') ALLOW = HttpResponse('allow')
DENY = HttpResponse('deny') DENY = HttpResponse('deny')
...@@ -144,24 +145,26 @@ def resource_endpoint(request): ...@@ -144,24 +145,26 @@ def resource_endpoint(request):
_webpage_client_userid(request) _webpage_client_userid(request)
and _resource_authorized_webpage_client(request) and _resource_authorized_webpage_client(request)
): ):
LOGGER.debug( if CLIENT_DEBUGGING:
'Granted %s access to resource %s %s to client', LOGGER.debug(
permission, 'Granted %s access to resource %s %s to client',
resource, permission,
name, resource,
) name,
)
return ALLOW return ALLOW
if ( if (
_apiclient_valid(request) _apiclient_valid(request)
and _resource_authorized_apiclient(request) and _resource_authorized_apiclient(request)
): ):
LOGGER.debug( if CLIENT_DEBUGGING:
'Granted %s access to resource %s %s to client', LOGGER.debug(
permission, 'Granted %s access to resource %s %s to client',
resource, permission,
name, resource,
) name,
)
return ALLOW return ALLOW
LOGGER.error( LOGGER.error(
...@@ -187,12 +190,13 @@ def topic_endpoint(request): ...@@ -187,12 +190,13 @@ def topic_endpoint(request):
routing_key == webpage_client_userid routing_key == webpage_client_userid
and not 'write' in permission and not 'write' in permission
): ):
LOGGER.debug( if CLIENT_DEBUGGING:
'Granted %s access to %s %s to client', LOGGER.debug(
permission, 'Granted %s access to %s %s to client',
resource, permission,
routing_key, resource,
) routing_key,
)
return ALLOW return ALLOW
LOGGER.error( LOGGER.error(
......
...@@ -3,7 +3,7 @@ import logging ...@@ -3,7 +3,7 @@ import logging
from rest_framework import generics, views from rest_framework import generics, views
from rest_framework.authentication import BasicAuthentication from rest_framework.authentication import BasicAuthentication
from rest_framework.response import Response from rest_framework.response import Response
from .serializers import SiteSerializer, ServiceSerializer, RabbitMQInstanceSerializer from .serializers import DeploymentTaskSerializer, ServiceSerializer, RabbitMQInstanceSerializer
from ..models import RabbitMQInstance from ..models import RabbitMQInstance
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
...@@ -13,12 +13,12 @@ LOGGER = logging.getLogger(__name__) ...@@ -13,12 +13,12 @@ LOGGER = logging.getLogger(__name__)
AUTHENTICATION_CLASSES = (BasicAuthentication, ) AUTHENTICATION_CLASSES = (BasicAuthentication, )
class DeploymentsView(generics.RetrieveAPIView): class DeploymentsView(generics.ListAPIView):
authentication_classes = AUTHENTICATION_CLASSES authentication_classes = AUTHENTICATION_CLASSES
serializer_class = SiteSerializer serializer_class = DeploymentTaskSerializer
def get_object(self): def get_queryset(self):
return self.request.user.site return self.request.user.site.tasks
# the client has to fetch the configuration (like services etc.) here # the client has to fetch the configuration (like services etc.) here
...@@ -45,8 +45,7 @@ class AckView(views.APIView): ...@@ -45,8 +45,7 @@ class AckView(views.APIView):
# find the corresponding task for this item # find the corresponding task for this item
for item in request.user.site.task_items.all(): for item in request.user.site.task_items.all():
if item.task.id == int(task_id): if item.task.id == int(task_id):
item.task.item_finished(request.user.site) item.success()
LOGGER.debug('Got acknowledgement for task %s', task_id)
return Response({'ok': True}) return Response({'ok': True})
# this is no critical # this is no critical
...@@ -71,15 +70,15 @@ class ResponseView(views.APIView): ...@@ -71,15 +70,15 @@ class ResponseView(views.APIView):
if task_item is not None: if task_item is not None:
if status == 'success': if status == 'success':
task_item.task.item_finished(request.user.site) task_item.done()
return Response({'ok': True}) return Response({'ok': True})
elif status == 'fail': elif status == 'fail':
task_item.task.item_failed(request.user.site) task_item.failed()
return Response({'ok': True}) return Response({'ok': True})
elif status == 'reject': elif status == 'reject':
task_item.task.item_rejected(request.user.site) task_item.rejected(request.data['output']['questionnaire'])
return Response({'ok': True}) return Response({'ok': True})
LOGGER.info('%s executed the obsolete task#%s', request.user, task_id) LOGGER.info('%s executed the obsolete task#%s', request.user, task_id)
......
...@@ -24,6 +24,7 @@ class DeploymentTaskItemSerializer(serializers.ModelSerializer): ...@@ -24,6 +24,7 @@ class DeploymentTaskItemSerializer(serializers.ModelSerializer):
service = ServiceSerializer() service = ServiceSerializer()
key = backend_serializers.SSHPublicKeySerializerB() key = backend_serializers.SSHPublicKeySerializerB()
site = SiteSerializer() site = SiteSerializer()
questionnaire = serializers.JSONField()
class Meta: class Meta:
model = models.DeploymentTaskItem model = models.DeploymentTaskItem
...@@ -33,6 +34,8 @@ class DeploymentTaskItemSerializer(serializers.ModelSerializer): ...@@ -33,6 +34,8 @@ class DeploymentTaskItemSerializer(serializers.ModelSerializer):
'service', 'service',
'site', 'site',
'state', 'state',
'questionnaire',
'id',
] ]
...@@ -45,6 +48,7 @@ class DeploymentTaskSerializer(serializers.ModelSerializer): ...@@ -45,6 +48,7 @@ class DeploymentTaskSerializer(serializers.ModelSerializer):
'action', 'action',
'key', 'key',
'service', 'service',
'id',
] ]
......
...@@ -6,4 +6,5 @@ URLPATTERNS = [ ...@@ -6,4 +6,5 @@ URLPATTERNS = [
url(r'^sshkey', views.SSHPublicKeyView.as_view()), url(r'^sshkey', views.SSHPublicKeyView.as_view()),
url(r'^deployments', views.DeploymentView.as_view()), url(r'^deployments', views.DeploymentView.as_view()),
url(r'^delete_user', views.UserDeletionView.as_view()), url(r'^delete_user', views.UserDeletionView.as_view()),
url(r'^questionnaire', views.QuestionnaireView.as_view()),
] ]
...@@ -129,6 +129,21 @@ class DeploymentView(views.APIView): ...@@ -129,6 +129,21 @@ class DeploymentView(views.APIView):
return _api_state_response(request) return _api_state_response(request)
class QuestionnaireView(views.APIView):
def post(self, request):
task_item_id = request.query_params.get('id', '')
if task_item_id != '':
item = models.DeploymentTaskItem.objects.filter(id=int(task_item_id))
if item.exists():
item.first().questionnaire_answered(
answers=request.data,
)
else:
LOGGER.error('Received questionnaire answer for non existing item')
return _api_state_response(request)
class UserDeletionView(views.APIView): class UserDeletionView(views.APIView):
def delete(self, request): def delete(self, request):
# this also logs out the user # this also logs out the user
......
...@@ -160,7 +160,8 @@ class RabbitMQInstance(SingletonModel): ...@@ -160,7 +160,8 @@ class RabbitMQInstance(SingletonModel):
) )
def publish_to_webpage(self, user, msg): def publish_to_webpage(self, user, msg):
LOGGER.debug('Signalling webpage of user %s', user) # noise
# LOGGER.debug('Signalling webpage of user %s', user)
self._publish( self._publish(
'update', 'update',
str(user.id), str(user.id),
...@@ -239,7 +240,7 @@ class User(AbstractUser): ...@@ -239,7 +240,7 @@ class User(AbstractUser):
@classmethod @classmethod
def construct_from_userinfo(cls, userinfo, idp): def construct_from_userinfo(cls, userinfo, idp):
LOGGER.debug('User: constructing from %s', userinfo) LOGGER.debug('Constructing User from:\n%s', userinfo)
if 'sub' not in userinfo: if 'sub' not in userinfo:
raise Exception('Missing attribute in userinfo: sub') raise Exception('Missing attribute in userinfo: sub')
...@@ -385,9 +386,12 @@ class Site(models.Model): ...@@ -385,9 +386,12 @@ class Site(models.Model):
# tasks which are still to be executed on this site # tasks which are still to be executed on this site
@property @property
def tasks(self): def tasks(self):
task_items = self.task_items.filter(state='pending')\
| self.task_items.filter(state='failed')\
| self.task_items.filter(state='answered')
return [item.task return [item.task
for item for item
in self.task_items.all()] in task_items]
class Service(models.Model): class Service(models.Model):
...@@ -465,7 +469,7 @@ class SSHPublicKey(models.Model): ...@@ -465,7 +469,7 @@ class SSHPublicKey(models.Model):
return self.name return self.name
# Deployment describes the credential state per user as it is supposed to be # Deployment describes the credential state per user (and site) as it is supposed to be
# #
# (exception: if is_active=False the ssh_keys contain the keys to be deployed # (exception: if is_active=False the ssh_keys contain the keys to be deployed
# if the deployment is reactivated) # if the deployment is reactivated)
...@@ -504,7 +508,6 @@ class Deployment(models.Model): ...@@ -504,7 +508,6 @@ class Deployment(models.Model):
def get_deployment(cls, user, service): def get_deployment(cls, user, service):
query = cls.objects.filter( query = cls.objects.filter(
user=user, user=user,
).filter(
service=service, service=service,
) )
if query.exists(): if query.exists():
...@@ -607,6 +610,15 @@ class Deployment(models.Model): ...@@ -607,6 +610,15 @@ class Deployment(models.Model):
self._withdraw_key(key) self._withdraw_key(key)
def invert_action(action):
if action == 'deploy':
return 'withdraw'
elif action == 'withdraw':
return 'deploy'
# DeploymentTask: knows:
# user, service, key, action
class DeploymentTask(models.Model): class DeploymentTask(models.Model):
ACTION_CHOICES = ( ACTION_CHOICES = (
('deploy', 'deploy'), ('deploy', 'deploy'),
...@@ -632,71 +644,122 @@ class DeploymentTask(models.Model): ...@@ -632,71 +644,122 @@ class DeploymentTask(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
# the inverse action of this task is requirred
# so we invert the task and manage its task_items accordingly
def invert_task(self):
LOGGER.debug(self.msg('inverting'))
previous_action = self.action
self.action = invert_action(previous_action)
self.save()
pending_sites = [task.site for task in self.task_items.all()]
self.chancel_items()
# sites which already executed the task
# we have to send them an order to rollback the changes
for site in self.deployment.service.site.all():
if site not in pending_sites:
deploy = DeploymentTaskItem(
task=self,
site=site,
user=self.deployment.user
)
deploy.save()
LOGGER.debug(deploy.msg('pending'))
@classmethod @classmethod
def construct_deployment_task(cls, deployment, key): def construct_deployment_task(cls, deployment, key):
# delete outstanding tasks which are made obsolete by this task # does a task exist for this key?
for withdrawal in deployment.withdrawals.filter(key=key): query = deployment.tasks.filter(key=key)
LOGGER.debug(withdrawal.msg('now obsolete')) if query.exists():
withdrawal.delete() if len(query) > 1:
raise Exception('Unexpected query result')
task = cls(
action='deploy', task = query.first()
deployment=deployment, if task.action == 'deploy':
key=key, raise Exception('Constructing deployment task when one already exists')
user=deployment.user,
) task.invert_task()
task.save() return task
LOGGER.debug(task.msg('generated'))
else:
# generate task items #create new task
for site in deployment.service.site.all(): task = cls(
deploy = DeploymentTaskItem( action='deploy',
task=task, deployment=deployment,
site=site, key=key,
user=deployment.user user=deployment.user,
) )
deploy.save() task.save()
LOGGER.debug(deploy.msg('generated')) LOGGER.debug(task.msg('pending'))
# generate task items
for site in deployment.service.site.all():
deploy = DeploymentTaskItem(
task=task,
site=site,
user=deployment.user
)
deploy.save()
LOGGER.debug(deploy.msg('pending'))
return task return task
@classmethod @classmethod
def construct_withdrawal_task(cls, deployment, key): def construct_withdrawal_task(cls, deployment, key):
# delete outstanding tasks which are made obsolete by this task # does a task exist for this key?
for withdrawal in deployment.deploys.filter(key=key): query = deployment.tasks.filter(key=key)
LOGGER.debug(withdrawal.msg('now obsolete')) if query.exists():
withdrawal.delete() if len(query) > 1:
raise Exception('Unexpected query result')
task = cls(
action='withdraw',
deployment=deployment,
key=key,
user=deployment.user,
)
task.save()
LOGGER.debug(task.msg('generated'))
# generate task items
for site in deployment.service.site.all():
deploy = DeploymentTaskItem(
task=task,
site=site,
user=deployment.user
)
deploy.save()
LOGGER.debug(deploy.msg('generated'))
return task task = query.first()
if task.action == 'withdraw':
raise Exception('Constructing deployment task when one already exists')
task.invert_task()
return task
else:
# create a new task
task = cls(
action='withdraw',
deployment=deployment,
key=key,
user=deployment.user,
)
task.save()
LOGGER.debug(task.msg('pending'))
# generate task items
for site in deployment.service.site.all():
deploy = DeploymentTaskItem(
task=task,
site=site,
user=deployment.user
)
deploy.save()
LOGGER.debug(deploy.msg('pending'))
return task
def chancel_items(self):
for item in self.task_items.all():
item.chancel()
def chancel(self):
self.chancel_items()
LOGGER.debug(self.msg("chanceled"))
self.delete()
@property @property
def service(self): def service(self):
return self.deployment.service return self.deployment.service
def __str__(self): def __str__(self):
return "{}:{}:{} - {}#{}".format( return "{}:{}#{}".format(
self.deployment.service, self.deployment.service,
self.deployment.user,
self.key,
self.action, self.action,
self.id, self.id,
) )
...@@ -725,42 +788,10 @@ class DeploymentTask(models.Model): ...@@ -725,42 +788,10 @@ class DeploymentTask(models.Model):
content, content,
) )
# the client acked the receipt and execution of the task for his site def try_finished(self):
def item_finished(self, site):
item = self.task_items.get(site=site)
# LOGGER.debug(item.msg('done'))
item.delete()
if not self.task_items.exists(): if not self.task_items.exists():
# finished sends its own message # finished sends its own message
self._finished() self._finished()
else:
self.send_state_update()
# the client failed to execute the item
# the client can try again later
# we signal the user about the failure
def item_failed(self, site):
item = self.task_items.get(site=site)
item.state = 'failed'
item.save()
self.send_state_update()
# TODO implement
# the client failed to execute the item
# the client needs additional information from the user to try again
# we have to ask the user for data
def item_rejected(self, site):
item = self.task_items.get(site=site)
item.state = 'rejected'
item.save()
self.send_state_update()
# TODO implement
# maintenance after all task items are done # maintenance after all task items are done
def _finished(self): def _finished(self):
...@@ -789,6 +820,11 @@ class DeploymentTask(models.Model): ...@@ -789,6 +820,11 @@ class DeploymentTask(models.Model):
) )
def questionnaire_default():
return {}
# DeploymentTaskItem: knows:
# user, service, key, action, _and_ site
class DeploymentTaskItem(models.Model): class DeploymentTaskItem(models.Model):
task = models.ForeignKey( task = models.ForeignKey(
DeploymentTask, DeploymentTask,
...@@ -811,6 +847,7 @@ class DeploymentTaskItem(models.Model): ...@@ -811,6 +847,7 @@ class DeploymentTaskItem(models.Model):
('chanceled', 'Chanceled'), ('chanceled', 'Chanceled'),
('failed', 'Failed'), ('failed', 'Failed'),
('rejected', 'Rejected'), ('rejected', 'Rejected'),
('answered', 'Answered'),
) )
state = models.CharField( state = models.CharField(
max_length=20, max_length=20,
...@@ -818,6 +855,12 @@ class DeploymentTaskItem(models.Model): ...@@ -818,6 +855,12 @@ class DeploymentTaskItem(models.Model):
default='pending', default='pending',
) )
questionnaire = JSONField(
default=questionnaire_default,