diff --git a/circle/common/operations.py b/circle/common/operations.py index 0423043..ed01532 100644 --- a/circle/common/operations.py +++ b/circle/common/operations.py @@ -13,6 +13,7 @@ class Operation(object): """ async_queue = 'localhost.man' required_perms = () + do_not_call_in_templates = True def __call__(self, **kwargs): return self.call(**kwargs) @@ -127,6 +128,19 @@ class OperatedMixin(object): raise AttributeError("%r object has no attribute %r" % (self.__class__.__name__, name)) + def get_available_operations(self, user): + """Yield Operations that match permissions of user and preconditions. + """ + for name in getattr(self, operation_registry_name, {}): + try: + op = getattr(self, name) + op.check_auth(user) + op.check_precond() + except: + pass # unavailable + else: + yield op + def register_operation(op_cls, op_id=None, target_cls=None): """Register the specified operation with the target class. diff --git a/circle/dashboard/forms.py b/circle/dashboard/forms.py index 154af2e..19ddee8 100644 --- a/circle/dashboard/forms.py +++ b/circle/dashboard/forms.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + from datetime import timedelta from django.contrib.auth.models import User diff --git a/circle/dashboard/models.py b/circle/dashboard/models.py index 7592ad3..07a0ae2 100644 --- a/circle/dashboard/models.py +++ b/circle/dashboard/models.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + from itertools import chain from logging import getLogger diff --git a/circle/dashboard/static/dashboard/vm-common.js b/circle/dashboard/static/dashboard/vm-common.js index 55599f3..c26738a 100644 --- a/circle/dashboard/static/dashboard/vm-common.js +++ b/circle/dashboard/static/dashboard/vm-common.js @@ -2,22 +2,21 @@ $(function() { - /* vm migrate */ - $('.vm-migrate').click(function(e) { - var icon = $(this).children("i"); - var vm = $(this).data("vm-pk"); - icon.removeClass("icon-truck").addClass("icon-spinner icon-spin"); + /* vm operations */ + $('#ops').on('click', '.operation.btn', function(e) { + var icon = $(this).children("i").addClass('icon-spinner icon-spin'); $.ajax({ type: 'GET', - url: '/dashboard/vm/' + vm + '/migrate/', + url: $(this).attr('href'), success: function(data) { - icon.addClass("icon-truck").removeClass("icon-spinner icon-spin"); + icon.removeClass("icon-spinner icon-spin"); $('body').append(data); - $('#create-modal').modal('show'); - $('#create-modal').on('hidden.bs.modal', function() { - $('#create-modal').remove(); + $('#confirmation-modal').modal('show'); + $('#confirmation-modal').on('hidden.bs.modal', function() { + $('#confirmation-modal').remove(); }); + $('#vm-migrate-node-list li').click(function(e) { var li = $(this).closest('li'); if (li.find('input').attr('disabled')) diff --git a/circle/dashboard/static/dashboard/vm-details.js b/circle/dashboard/static/dashboard/vm-details.js index 916f75b..dce3b55 100644 --- a/circle/dashboard/static/dashboard/vm-details.js +++ b/circle/dashboard/static/dashboard/vm-details.js @@ -211,6 +211,7 @@ function checkNewActivity(only_status, runs) { success: function(data) { if(!only_status) { $("#activity-timeline").html(data['activities']); + $("#ops").html(data['ops']); $("[title]").tooltip(); } diff --git a/circle/dashboard/tables.py b/circle/dashboard/tables.py index 301eb3b..14b508c 100644 --- a/circle/dashboard/tables.py +++ b/circle/dashboard/tables.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + from django.contrib.auth.models import Group, User from django_tables2 import Table, A from django_tables2.columns import (TemplateColumn, Column, BooleanColumn, diff --git a/circle/dashboard/templates/dashboard/_base.html b/circle/dashboard/templates/dashboard/_base.html new file mode 100644 index 0000000..572e73f --- /dev/null +++ b/circle/dashboard/templates/dashboard/_base.html @@ -0,0 +1,20 @@ +{% extends "dashboard/base.html" %} +{% load i18n %} + +{% block content %} + <div class="body-content"> + <div class="panel panel-default" style="margin-top: 60px;"> + <div class="panel-heading"> + <h3 class="no-margin"> + {% if title %} + {{ title }} + {% else %} + {% trans "Confirmation" %} + {% endif %} + </h3> + </div> + <div class="panel-body"> + {{ body|safe|default:"(body missing from context.)" }} + </div> + </div> +{% endblock %} diff --git a/circle/dashboard/templates/dashboard/_modal.html b/circle/dashboard/templates/dashboard/_modal.html new file mode 100644 index 0000000..cc9ab39 --- /dev/null +++ b/circle/dashboard/templates/dashboard/_modal.html @@ -0,0 +1,12 @@ +{% load i18n %} +<div class="modal fade" id="confirmation-modal" tabindex="-1" role="dialog"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-body"> + {{ body|safe|default:"(body missing from context.)" }} + <div class="clearfix"></div> + </div> + <div class="clearfix"></div> + </div><!-- /.modal-content --> + </div><!-- /.modal-dialog --> +</div> diff --git a/circle/dashboard/templates/dashboard/_vm-migrate.html b/circle/dashboard/templates/dashboard/_vm-migrate.html index e1ec92b..7fb36ce 100644 --- a/circle/dashboard/templates/dashboard/_vm-migrate.html +++ b/circle/dashboard/templates/dashboard/_vm-migrate.html @@ -1,10 +1,19 @@ +{% extends "dashboard/operate.html" %} {% load i18n %} {% load sizefieldtags %} -<form method="POST" action="{% url "dashboard.views.vm-migrate" pk=vm.pk %}"> - {% csrf_token %} - <ul id="vm-migrate-node-list"> - {% with current=vm.node.pk selected=vm.select_node.pk %} +{% block question %} +<p> +{% blocktrans with obj=object op=op.name %} +Choose a compute node to migrate {{obj}} to. +{% endblocktrans %} +</p> +<p class="text-info">{{op.name}}: {{op.description}}</p> +{% endblock %} + +{% block formfields %} + <ul id="vm-migrate-node-list" class="list-unstyled"> + {% with current=object.node.pk selected=object.select_node.pk %} {% for n in nodes %} <li class="panel panel-default"><div class="panel-body"> <label for="migrate-to-{{n.pk}}"> @@ -22,5 +31,4 @@ {% endfor %} {% endwith %} </ul> - <button type="submit" class="btn btn-primary btn-sm"><i class="icon-truck"></i> Migrate</button> -</form> +{% endblock %} diff --git a/circle/dashboard/templates/dashboard/operate.html b/circle/dashboard/templates/dashboard/operate.html new file mode 100644 index 0000000..a56b0a1 --- /dev/null +++ b/circle/dashboard/templates/dashboard/operate.html @@ -0,0 +1,19 @@ +{% load i18n %} + +{% block question %} +<p> +{% blocktrans with obj=object op=op.name %} +Do you want to do the following operation on {{obj}}: +<strong>{{op}}</strong>? +{% endblocktrans %} +</p> +<p class="text-info">{{op.name}}: {{op.description}}</p> +{% endblock %} +<form method="POST" action="{{url}}">{% csrf_token %} + {% block formfields %}{% endblock %} + <div class="pull-right"> + <a class="btn btn-default" href="{{object.get_absolute_url}}" + data-dismiss="modal">{% trans "Cancel" %}</a> + <button class="btn btn-danger" type="submit">{% if op.icon %}<i class="icon-{{op.icon}}"></i> {% endif %}{{ op|capfirst }}</button> + </div> +</form> diff --git a/circle/dashboard/templates/dashboard/vm-detail.html b/circle/dashboard/templates/dashboard/vm-detail.html index 2bcdcbd..799557f 100644 --- a/circle/dashboard/templates/dashboard/vm-detail.html +++ b/circle/dashboard/templates/dashboard/vm-detail.html @@ -6,54 +6,8 @@ {% block content %} <div class="body-content"> <div class="page-header"> - <div class="pull-right" style="padding-top: 15px;"> - <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}"> - {% csrf_token %} - <input type="hidden" name="sleep" /> - <button title="{% trans "Sleep" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-moon"></i></button> - </form> - <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}"> - {% csrf_token %} - <input type="hidden" name="deploy" /> - <button title="{% trans "Deploy" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-play"></i></button> - </form> - <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}"> - {% csrf_token %} - <input type="hidden" name="wake_up" /> - <button title="{% trans "Wake up" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-sun"></i></button> - </form> - <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}"> - {% csrf_token %} - <input type="hidden" name="shut_down" /> - <button title="{% trans "Shut down" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-off"></i></button> - </form> - <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}"> - {% csrf_token %} - <input type="hidden" name="reboot" /> - <button title="{% trans "Reboot (ctrl + alt + del)" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-refresh"></i></button> - </form> - <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}"> - {% csrf_token %} - <input type="hidden" name="reset" /> - <button title="{% trans "Reset (power cycle)" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-bolt"></i></button> - </form> - <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}"> - {% csrf_token %} - <input type="hidden" name="shut_off"/> - <button title="{% trans "Shut off" %}" class="btn btn-default btn-xs" type="submit"> - <i class="icon-ban-circle"></i> - </button> - </form> - <a title="Migrate" data-vm-pk="{{ instance.pk }}" href="{% url "dashboard.views.vm-migrate" pk=instance.pk %}" class="btn btn-default btn-xs vm-migrate"> - <i class="icon-truck"></i> - </a> - <form style="display: inline;" method="POST" action="{% url "dashboard.views.detail" pk=instance.pk %}"> - {% csrf_token %} - <input type="hidden" name="save_as" /> - <button title="{% trans "Save as template" %}" class="btn btn-default btn-xs" type="submit"><i class="icon-save"></i></button> - </form> - <a title="{% trans "Destroy" %}" href="{% url "dashboard.views.delete-vm" pk=instance.pk %}" class="btn btn-default btn-xs vm-delete" data-vm-pk="{{ instance.pk }}"><i class="icon-remove"></i></a> - <a title="{% trans "Help" %}" href="#" class="btn btn-default btn-xs vm-details-help-button"><i class="icon-question"></i></a> + <div class="pull-right" style="padding-top: 15px;" id="ops"> + {% include "dashboard/vm-detail/_operations.html" %} </div> <h1> <div id="vm-details-rename"> @@ -68,46 +22,6 @@ </div> <small>{{ instance.primary_host.get_fqdn }}</small> </h1> - <div class="vm-details-help js-hidden"> - <ul style="list-style: none;"> - <li> - <strong>{% trans "Sleep" %}:</strong> - {% trans "Suspend virtual machine with memory dump." %} - </li> - <li> - <strong>{% trans "Wake up" %}:</strong> - {% trans "Wake up suspended machine." %} - </li> - <li> - <strong>{% trans "Shutdown" %}:</strong> - {% trans "Shutdown virtual machine with ACPI signal." %} - </li> - <li> - <strong>{% trans "Reboot (ctrl + alt + del)" %}:</strong> - {% trans "Reboot virtual machine with Ctrl+Alt+Del signal." %} - </li> - <li> - <strong>{% trans "Reset (power cycle)" %}:</strong> - {% trans "Reset virtual machine (reset button)" %} - </li> - <li> - <strong>{% trans "Shut off" %}:</strong> - {% trans "Shut off VM. (plug-out)" %} - </li> - <li> - <strong>{% trans "Migrate" %}:</strong> - {% trans "Live migrate running vm to another node." %} - </li> - <li> - <strong>{% trans "Save as template" %}:</strong> - {% trans "Shut down the virtual machine, and save it as a new template." %} - </li> - <li> - <strong>{% trans "Destroy" %}:</strong> - {% trans "Remove virtual machine and its networks." %} - </li> - </ul> - </div> <div style="clear: both;"></div> </div> <div class="row"> diff --git a/circle/dashboard/templates/dashboard/vm-detail/_operations.html b/circle/dashboard/templates/dashboard/vm-detail/_operations.html new file mode 100644 index 0000000..a28ad1a --- /dev/null +++ b/circle/dashboard/templates/dashboard/vm-detail/_operations.html @@ -0,0 +1,9 @@ +{% load i18n %} + +{% for op in ops %} +<a href="{{op.get_url}}" class="operation operation-{{op.op}} btn btn-default btn-xs" + title="{{op.name}}: {{op.description}}"> + <i class="icon-{{op.icon}}"></i> + <span class="sr-only">{{op.name}}</span> +</a> +{% endfor %} diff --git a/circle/dashboard/tests/test_mockedviews.py b/circle/dashboard/tests/test_mockedviews.py index 0dd9468..4761305 100644 --- a/circle/dashboard/tests/test_mockedviews.py +++ b/circle/dashboard/tests/test_mockedviews.py @@ -3,10 +3,11 @@ from factory import Factory, Sequence from mock import patch, MagicMock from django.contrib.auth.models import User -# from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied from django.http import HttpRequest, Http404 -from dashboard.views import InstanceActivityDetail, InstanceActivity +from ..views import InstanceActivityDetail, InstanceActivity +from ..views import vm_ops, Instance class ViewUserTestCase(unittest.TestCase): @@ -36,6 +37,81 @@ class ViewUserTestCase(unittest.TestCase): self.assertEquals(view(request, pk=1234).render().status_code, 200) +class VmOperationViewTestCase(unittest.TestCase): + + def test_available(self): + request = FakeRequestFactory(superuser=True) + view = vm_ops['destroy'] + + with patch.object(view, 'get_object') as go: + inst = MagicMock(spec=Instance) + inst._meta.object_name = "Instance" + inst.destroy = Instance._ops['destroy'](inst) + go.return_value = inst + self.assertEquals( + view.as_view()(request, pk=1234).render().status_code, 200) + + def test_unpermitted(self): + request = FakeRequestFactory() + view = vm_ops['destroy'] + + with patch.object(view, 'get_object') as go: + inst = MagicMock(spec=Instance) + inst._meta.object_name = "Instance" + inst.destroy = Instance._ops['destroy'](inst) + inst.has_level.return_value = False + go.return_value = inst + with self.assertRaises(PermissionDenied): + view.as_view()(request, pk=1234).render() + + def test_migrate(self): + request = FakeRequestFactory(POST={'node': 1}) + view = vm_ops['migrate'] + + with patch.object(view, 'get_object') as go, \ + patch('dashboard.views.messages') as msg, \ + patch('dashboard.views.get_object_or_404') as go4: + inst = MagicMock(spec=Instance) + inst._meta.object_name = "Instance" + inst.migrate = Instance._ops['migrate'](inst) + inst.migrate.async = MagicMock() + inst.has_level.return_value = True + go.return_value = inst + go4.return_value = MagicMock() + assert view.as_view()(request, pk=1234)['location'] + assert not msg.error.called + + def test_migrate_failed(self): + request = FakeRequestFactory(POST={'node': 1}) + view = vm_ops['migrate'] + + with patch.object(view, 'get_object') as go, \ + patch('dashboard.views.messages') as msg, \ + patch('dashboard.views.get_object_or_404') as go4: + inst = MagicMock(spec=Instance) + inst._meta.object_name = "Instance" + inst.migrate = Instance._ops['migrate'](inst) + inst.migrate.async = MagicMock() + inst.migrate.async.side_effect = Exception + inst.has_level.return_value = True + go.return_value = inst + go4.return_value = MagicMock() + assert view.as_view()(request, pk=1234)['location'] + assert msg.error.called + + def test_migrate_template(self): + request = FakeRequestFactory() + view = vm_ops['migrate'] + + with patch.object(view, 'get_object') as go: + inst = MagicMock(spec=Instance) + inst._meta.object_name = "Instance" + inst.migrate = Instance._ops['migrate'](inst) + inst.has_level.return_value = True + go.return_value = inst + self.assertEquals( + view.as_view()(request, pk=1234).render().status_code, 200) + def FakeRequestFactory(*args, **kwargs): ''' FakeRequestFactory, FakeMessages and FakeRequestContext are good for mocking out django views; they are MUCH faster than the Django test client. @@ -48,12 +124,12 @@ def FakeRequestFactory(*args, **kwargs): request = HttpRequest() request.user = user request.session = kwargs.get('session', {}) - if kwargs.get('POST'): + if kwargs.get('POST') is not None: request.method = 'POST' request.POST = kwargs.get('POST') else: request.method = 'GET' - request.POST = kwargs.get('GET', {}) + request.GET = kwargs.get('GET', {}) return request diff --git a/circle/dashboard/tests/test_views.py b/circle/dashboard/tests/test_views.py index c834269..0daac0d 100644 --- a/circle/dashboard/tests/test_views.py +++ b/circle/dashboard/tests/test_views.py @@ -495,9 +495,11 @@ class VmDetailTest(LoginMixin, TestCase): mock_method.side_effect = inst.wake_up inst.manual_state_change('RUNNING') inst.set_level(self.u2, 'owner') - self.assertRaises(inst.WrongStateError, c.post, - "/dashboard/vm/1/", {'wake_up': True}) - self.assertEqual(inst.status, 'RUNNING') + with patch('dashboard.views.messages') as msg: + c.post("/dashboard/vm/1/op/wake_up/") + assert msg.error.called + inst = Instance.objects.get(pk=1) + self.assertEqual(inst.status, 'RUNNING') # mocked anyway assert mock_method.called def test_permitted_wake_up(self): @@ -511,7 +513,9 @@ class VmDetailTest(LoginMixin, TestCase): inst.get_remote_queue_name = Mock(return_value='test') inst.manual_state_change('SUSPENDED') inst.set_level(self.u2, 'owner') - response = c.post("/dashboard/vm/1/", {'wake_up': True}) + with patch('dashboard.views.messages') as msg: + response = c.post("/dashboard/vm/1/op/wake_up/") + assert not msg.error.called self.assertEqual(response.status_code, 302) self.assertEqual(inst.status, 'RUNNING') assert new_wake_up.called @@ -523,8 +527,11 @@ class VmDetailTest(LoginMixin, TestCase): inst = Instance.objects.get(pk=1) inst.manual_state_change('SUSPENDED') inst.set_level(self.u2, 'user') - response = c.post("/dashboard/vm/1/", {'wake_up': True}) - self.assertEqual(response.status_code, 403) + with patch('dashboard.views.messages') as msg: + response = c.post("/dashboard/vm/1/op/wake_up/") + assert msg.error.called + self.assertEqual(response.status_code, 302) + inst = Instance.objects.get(pk=1) self.assertEqual(inst.status, 'SUSPENDED') def test_non_existing_template_get(self): diff --git a/circle/dashboard/urls.py b/circle/dashboard/urls.py index 94e8776..5161b30 100644 --- a/circle/dashboard/urls.py +++ b/circle/dashboard/urls.py @@ -1,4 +1,5 @@ -from django.conf.urls import patterns, url +from __future__ import absolute_import +from django.conf.urls import patterns, url, include from vm.models import Instance from .views import ( @@ -34,7 +35,7 @@ urlpatterns = patterns( name="dashboard.views.template-list"), url(r"^template/delete/(?P<pk>\d+)/$", TemplateDelete.as_view(), name="dashboard.views.template-delete"), - + url(r'^vm/(?P<pk>\d+)/op/', include('dashboard.vm.urls')), url(r'^vm/(?P<pk>\d+)/remove_port/(?P<rule>\d+)/$', PortDelete.as_view(), name='dashboard.views.remove-port'), url(r'^vm/(?P<pk>\d+)/$', VmDetailView.as_view(), diff --git a/circle/dashboard/views.py b/circle/dashboard/views.py index 385a29a..3c4ed3f 100644 --- a/circle/dashboard/views.py +++ b/circle/dashboard/views.py @@ -1,10 +1,9 @@ -from __future__ import unicode_literals +from __future__ import unicode_literals, absolute_import from os import getenv import json import logging import re -from datetime import datetime import requests from django.conf import settings @@ -47,7 +46,7 @@ from vm.models import ( ) from storage.models import Disk from firewall.models import Vlan, Host, Rule -from dashboard.models import Favourite, Profile +from .models import Favourite, Profile logger = logging.getLogger(__name__) @@ -203,7 +202,8 @@ class VmDetailView(CheckedDetailView): context.update({ 'graphite_enabled': VmGraphView.get_graphite_url() is not None, 'vnc_url': reverse_lazy("dashboard.views.detail-vnc", - kwargs={'pk': self.object.pk}) + kwargs={'pk': self.object.pk}), + 'ops': get_operations(instance, self.request.user), }) # activity data @@ -242,14 +242,6 @@ class VmDetailView(CheckedDetailView): 'to_remove': self.__remove_tag, 'port': self.__add_port, 'new_network_vlan': self.__new_network, - 'save_as': self.__save_as, - 'shut_down': self.__shut_down, - 'sleep': self.__sleep, - 'wake_up': self.__wake_up, - 'deploy': self.__deploy, - 'reset': self.__reset, - 'reboot': self.__reboot, - 'shut_off': self.__shut_off, } for k, v in options.iteritems(): if request.POST.get(k) is not None: @@ -414,75 +406,132 @@ class VmDetailView(CheckedDetailView): return redirect("%s#network" % reverse_lazy( "dashboard.views.detail", kwargs={'pk': self.object.pk})) - def __save_as(self, request): - self.object = self.get_object() - if not self.object.has_level(request.user, 'owner'): - raise PermissionDenied() - date = datetime.now().strftime("%Y-%m-%d %H:%M") - new_name = "Saved from %s (#%d) at %s" % ( - self.object.name, self.object.pk, date - ) - self.object.save_as_template.async(name=new_name, - user=request.user) - messages.success(request, _("Saving instance as template!")) - return redirect("%s#activity" % self.object.get_absolute_url()) +class OperationView(DetailView): - def __shut_down(self, request): - self.object = self.get_object() - if not self.object.has_level(request.user, 'owner'): - raise PermissionDenied() + template_name = 'dashboard/operate.html' - self.object.shutdown.async(user=request.user) - return redirect("%s#activity" % self.object.get_absolute_url()) + @property + def name(self): + return self.get_op().name - def __sleep(self, request): - self.object = self.get_object() - if not self.object.has_level(request.user, 'owner'): - raise PermissionDenied() + @property + def description(self): + return self.get_op().description - self.object.sleep.async(user=request.user) - return redirect("%s#activity" % self.object.get_absolute_url()) + @classmethod + def get_urlname(cls): + return 'dashboard.vm.op.%s' % cls.op - def __wake_up(self, request): - self.object = self.get_object() - if not self.object.has_level(request.user, 'owner'): - raise PermissionDenied() + def get_url(self): + return reverse(self.get_urlname(), args=(self.get_object().pk, )) - self.object.wake_up.async(user=request.user) - return redirect("%s#activity" % self.object.get_absolute_url()) + def get_wrapper_template_name(self): + if self.request.is_ajax(): + return 'dashboard/_modal.html' + else: + return 'dashboard/_base.html' - def __deploy(self, request): - self.object = self.get_object() - if not self.object.has_level(request.user, 'owner'): - raise PermissionDenied() + @classmethod + def get_op_by_object(cls, obj): + return getattr(obj, cls.op) - self.object.deploy.async(user=request.user) - return redirect("%s#activity" % self.object.get_absolute_url()) + def get_op(self): + if not hasattr(self, '_opobj'): + setattr(self, '_opobj', getattr(self.get_object(), self.op)) + return self._opobj - def __reset(self, request): - self.object = self.get_object() - if not self.object.has_level(request.user, 'owner'): - raise PermissionDenied() + def get_context_data(self, **kwargs): + ctx = super(OperationView, self).get_context_data(**kwargs) + ctx['op'] = self.get_op() + ctx['url'] = self.request.path + return ctx - self.object.reset.async(user=request.user) - return redirect("%s#activity" % self.object.get_absolute_url()) + def get(self, request, *args, **kwargs): + self.get_op().check_auth(request.user) + response = super(OperationView, self).get(request, *args, **kwargs) + response.render() + response.content = render_to_string(self.get_wrapper_template_name(), + {'body': response.content}) + return response - def __reboot(self, request): + def post(self, request, extra=None, *args, **kwargs): self.object = self.get_object() - if not self.object.has_level(request.user, 'owner'): - raise PermissionDenied() - - self.object.reboot.async(user=request.user) + if extra is None: + extra = {} + try: + self.get_op().async(user=request.user, **extra) + except Exception as e: + messages.error(request, _('Could not start operation.')) + logger.error(e) return redirect("%s#activity" % self.object.get_absolute_url()) - def __shut_off(self, request): - self.object = self.get_object() - if not self.object.has_level(request.user, 'owner'): - raise PermissionDenied() + @classmethod + def factory(cls, op, icon='cog'): + return type(str(cls.__name__ + op), + (cls, ), {'op': op, 'icon': icon}) - self.object.shut_off.async(user=request.user) - return redirect("%s#activity" % self.object.get_absolute_url()) + @classmethod + def bind_to_object(cls, instance): + v = cls() + v.get_object = lambda: instance + return v + + +class VmOperationView(OperationView): + + model = Instance + + +class VmMigrateView(VmOperationView): + + op = 'migrate' + icon = 'truck' + template_name = 'dashboard/_vm-migrate.html' + + def get_context_data(self, **kwargs): + ctx = super(VmOperationView, self).get_context_data(**kwargs) + ctx['nodes'] = [n for n in Node.objects.filter(enabled=True) + if n.state == "ONLINE"] + return ctx + + def post(self, request, extra=None, *args, **kwargs): + if extra is None: + extra = {} + node = self.request.POST.get("node") + if node: + node = get_object_or_404(Node, pk=node) + extra["to_node"] = node + return super(VmMigrateView, self).post(request, extra, *args, **kwargs) + + +vm_ops = { + 'reset': VmOperationView.factory(op='reset', icon='bolt'), + 'deploy': VmOperationView.factory(op='deploy', icon='play'), + 'migrate': VmMigrateView, + 'reboot': VmOperationView.factory(op='reboot', icon='refresh'), + 'shut_off': VmOperationView.factory(op='shut_off', icon='ban-circle'), + 'shutdown': VmOperationView.factory(op='shutdown', icon='off'), + 'save_as_template': VmOperationView.factory( + op='save_as_template', icon='save'), + 'destroy': VmOperationView.factory(op='destroy', icon='remove'), + 'sleep': VmOperationView.factory(op='sleep', icon='moon'), + 'wake_up': VmOperationView.factory(op='wake_up', icon='sun'), +} + + +def get_operations(instance, user): + ops = [] + for k, v in vm_ops.iteritems(): + try: + op = v.get_op_by_object(instance) + op.check_auth(user) + op.check_precond() + except: + pass # unavailable + else: + ops.append(v.bind_to_object(instance)) + return ops class NodeDetailView(LoginRequiredMixin, SuperuserRequiredMixin, DetailView): @@ -1592,23 +1641,28 @@ def vm_activity(request, pk): raise PermissionDenied() response = {} - only_status = request.GET.get("only_status") + only_status = request.GET.get("only_status", "false") response['human_readable_status'] = instance.get_status_display() response['status'] = instance.status response['icon'] = instance.get_status_icon() if only_status == "false": # instance activity context = { + 'instance': instance, 'activities': InstanceActivity.objects.filter( instance=instance, parent=None - ).order_by('-started').select_related() + ).order_by('-started').select_related(), + 'ops': get_operations(instance, request.user), } - activities = render_to_string( + response['activities'] = render_to_string( "dashboard/vm-detail/_activity-timeline.html", RequestContext(request, context), ) - response['activities'] = activities + response['ops'] = render_to_string( + "dashboard/vm-detail/_operations.html", + RequestContext(request, context), + ) return HttpResponse( json.dumps(response), @@ -2018,40 +2072,6 @@ class NotificationView(LoginRequiredMixin, TemplateView): return response -class VmMigrateView(SuperuserRequiredMixin, TemplateView): - - def get_template_names(self): - if self.request.is_ajax(): - return ['dashboard/modal-wrapper.html'] - else: - return ['dashboard/nojs-wrapper.html'] - - def get(self, request, form=None, *args, **kwargs): - context = self.get_context_data(**kwargs) - vm = Instance.objects.get(pk=kwargs['pk']) - context.update({ - 'template': 'dashboard/_vm-migrate.html', - 'box_title': _('Migrate %(name)s' % {'name': vm.name}), - 'ajax_title': True, - 'vm': vm, - 'nodes': [n for n in Node.objects.filter(enabled=True) - if n.state == "ONLINE"] - }) - return self.render_to_response(context) - - def post(self, *args, **kwargs): - node = self.request.POST.get("node") - vm = Instance.objects.get(pk=kwargs['pk']) - - if node: - node = Node.objects.get(pk=node) - vm.migrate.async(to_node=node, user=self.request.user) - else: - messages.error(self.request, _("You didn't select a node!")) - - return redirect("%s#activity" % vm.get_absolute_url()) - - def circle_login(request): authentication_form = CircleAuthenticationForm extra_context = { diff --git a/circle/dashboard/vm/__init__.py b/circle/dashboard/vm/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/circle/dashboard/vm/__init__.py diff --git a/circle/dashboard/vm/urls.py b/circle/dashboard/vm/urls.py new file mode 100644 index 0000000..d421d0c --- /dev/null +++ b/circle/dashboard/vm/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls import patterns, url + +from ..views import vm_ops + + +urlpatterns = patterns('', + *(url(r'^%s/$' % op, v.as_view(), name=v.get_urlname()) + for op, v in vm_ops.iteritems())) diff --git a/circle/vm/operations.py b/circle/vm/operations.py index 4fee39d..b25694d 100644 --- a/circle/vm/operations.py +++ b/circle/vm/operations.py @@ -60,6 +60,7 @@ class DeployOperation(InstanceOperation): id = 'deploy' name = _("deploy") description = _("Deploy new virtual machine with network.") + icon = 'play' def on_commit(self, activity): activity.resultant_state = 'RUNNING' @@ -96,6 +97,7 @@ class DestroyOperation(InstanceOperation): id = 'destroy' name = _("destroy") description = _("Destroy virtual machine and its networks.") + icon = 'remove' def on_commit(self, activity): activity.resultant_state = 'DESTROYED' @@ -136,6 +138,7 @@ class MigrateOperation(InstanceOperation): id = 'migrate' name = _("migrate") description = _("Live migrate running VM to another node.") + icon = 'truck' def _operation(self, activity, user, system, to_node=None, timeout=120): if not to_node: @@ -166,6 +169,7 @@ class RebootOperation(InstanceOperation): id = 'reboot' name = _("reboot") description = _("Reboot virtual machine with Ctrl+Alt+Del signal.") + icon = 'refresh' def _operation(self, activity, user, system, timeout=5): self.instance.reboot_vm(timeout=timeout) @@ -179,6 +183,7 @@ class ResetOperation(InstanceOperation): id = 'reset' name = _("reset") description = _("Reset virtual machine (reset button).") + icon = 'bolt' def _operation(self, activity, user, system, timeout=5): self.instance.reset_vm(timeout=timeout) @@ -195,6 +200,7 @@ class SaveAsTemplateOperation(InstanceOperation): Template can be shared with groups and users. Users can instantiate Virtual Machines from Templates. """) + icon = 'save' def _operation(self, activity, name, user, system, timeout=300, with_shutdown=True, **kwargs): @@ -260,6 +266,7 @@ class ShutdownOperation(InstanceOperation): id = 'shutdown' name = _("shutdown") description = _("Shutdown virtual machine with ACPI signal.") + icon = 'off' def check_precond(self): super(ShutdownOperation, self).check_precond() @@ -289,6 +296,7 @@ class ShutOffOperation(InstanceOperation): id = 'shut_off' name = _("shut off") description = _("Shut off VM (plug-out).") + icon = 'ban-circle' def on_commit(self, activity): activity.resultant_state = 'STOPPED' @@ -315,6 +323,7 @@ class SleepOperation(InstanceOperation): id = 'sleep' name = _("sleep") description = _("Suspend virtual machine with memory dump.") + icon = 'moon' def check_precond(self): super(SleepOperation, self).check_precond() @@ -354,6 +363,7 @@ class WakeUpOperation(InstanceOperation): Power on Virtual Machine and load its memory from dump. """) + icon = 'sun' def check_precond(self): super(WakeUpOperation, self).check_precond()