Commit 113c216a by Bach Dániel

Merge branch 'issue-298' into 'master'

Issue 298

closes #298
parents 34bf3f22 0a778b39
......@@ -273,3 +273,4 @@ def register_operation(op_cls, op_id=None, target_cls=None):
setattr(target_cls, operation_registry_name, dict())
getattr(target_cls, operation_registry_name)[op_id] = op_cls
return op_cls
......@@ -19,8 +19,7 @@ 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,
LinkColumn)
from django_tables2.columns import TemplateColumn, Column, LinkColumn
from vm.models import Node, InstanceTemplate, Lease
from django.utils.translation import ugettext_lazy as _
......@@ -40,8 +39,10 @@ class NodeListTable(Table):
attrs={'th': {'class': 'node-list-table-thin'}},
)
enabled = BooleanColumn(
get_status_display = Column(
verbose_name=_("Status"),
attrs={'th': {'class': 'node-list-table-thin'}},
order_by=("enabled", "schedule_enabled"),
)
name = TemplateColumn(
......@@ -66,20 +67,12 @@ class NodeListTable(Table):
orderable=False,
)
actions = TemplateColumn(
verbose_name=_("Actions"),
attrs={'th': {'class': 'node-list-table-thin'}},
template_code=('{% include "dashboard/node-list/column-'
'actions.html" with btn_size="btn-xs" %}'),
orderable=False,
)
class Meta:
model = Node
attrs = {'class': ('table table-bordered table-striped table-hover '
'node-list-table')}
fields = ('pk', 'name', 'host', 'enabled', 'priority', 'overcommit',
'number_of_VMs', )
fields = ('pk', 'name', 'host', 'get_status_display', 'priority',
'overcommit', 'number_of_VMs', )
class GroupListTable(Table):
......
......@@ -18,6 +18,8 @@ Choose a compute node to migrate {{obj}} to.
<li class="panel panel-default"><div class="panel-body">
<label for="migrate-to-{{n.pk}}">
<strong>{{ n }}</strong>
<div class="label label-primary"><i class="fa {{n.get_status_icon}}"></i>
{{n.get_status_display}}</div>
{% if current == n.pk %}<div class="label label-info">{% trans "current" %}</div>{% endif %}
{% if selected == n.pk %}<div class="label label-success">{% trans "recommended" %}</div>{% endif %}
</label>
......
{% 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">
{% if text %}
{{ text }}
{% else %}
{%blocktrans with object=object%}
Are you sure you want to flush <strong>{{ object }}</strong>?
{%endblocktrans%}
{% endif %}
<div class="pull-right">
<form action="{% url "dashboard.views.flush-node" pk=node.pk %}?next={{next}}" method="POST">
{% csrf_token %}
<button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Cancel" %}</button>
<input type="hidden" name="flush" value=""/>
<button class="btn btn-warning">{% trans "Yes" %}</button>
</form>
</div>
<div class="clearfix"></div>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div>
......@@ -6,13 +6,12 @@
{% block content %}
<div class="body-content">
<div class="page-header">
<div class="pull-right" id="ops">
{% include "dashboard/vm-detail/_operations.html" %}
</div>
<div class="pull-right" style="padding-top: 15px;">
<a title="{% trans "Rename" %}" href="#" class="btn btn-default btn-xs node-details-rename-button"><i class="fa fa-pencil"></i></a>
<a title="{% trans "Flush" %}" data-node-pk="{{ node.pk }}" class="btn btn-default btn-xs real-link node-flush" href="{% url "dashboard.views.flush-node" pk=node.pk %}"><i class="fa fa-cloud-upload"></i></a>
<a title="{% trans "Enable" %}" style="display:{% if node.enabled %}none{% else %}inline-block{% endif %}" data-node-pk="{{ node.pk }}" class="btn btn-default btn-xs real-link node-enable" href="{% url "dashboard.views.status-node" pk=node.pk %}?next={{ request.path }}"><i class="fa fa-check"></i></a>
<a title="{% trans "Disable" %}" style="display:{% if not node.enabled %}none{% else %}inline-block{% endif %}" data-node-pk="{{ node.pk }}" class="btn btn-default btn-xs real-link node-enable" href="{% url "dashboard.views.status-node" pk=node.pk %}?next={{ request.path }}"><i class="fa fa-ban"></i></a>
<a title="{% trans "Delete" %}" data-node-pk="{{ node.pk }}" class="btn btn-default btn-xs real-link node-delete" href="{% url "dashboard.views.delete-node" pk=node.pk %}"><i class="fa fa-trash-o"></i></a>
<a title="{% trans "Help" %}" href="#" class="btn btn-default btn-xs node-details-help-button"><i class="fa fa-question"></i></a>
</div>
<h1>
<div id="node-details-rename">
......@@ -26,42 +25,34 @@
{{ node.name }}
</div>
</h1>
<div class="node-details-help js-hidden">
<ul style="list-style: none;">
<li>
<strong>{% trans "Rename" %}:</strong>
{% trans "Change the name of the node." %}
</li>
<li>
<strong>{% trans "Flush" %}:</strong>
{% trans "Disable node and move all instances to other one." %}
</li>
<li>
<strong>{% trans "Enable" %}:</strong>
{% trans "Enables node." %}
</li>
<li>
<strong>{% trans "Disable" %}:</strong>
{% trans "Disables node." %}
</li>
<li>
<strong>{% trans "Delete" %}:</strong>
{% trans "Remove node and it's host." %}
</li>
</ul>
</div>
</div>
<div class="row">
<div class="col-md-2" id="node-info-pane">
<div id="node-info-data" class="big">
<span id="node-details-state" class="label
{% if node.state == 'ONLINE' %}label-success
{% elif node.state == 'MISSING' %}label-danger
{% elif node.state == 'DISABLED' %}label-warning
{% elif node.state == 'OFFLINE' %}label-warning{% endif %}">
{% if node.state == 'ACTIVE' %}label-success
{% elif node.state == 'PASSIVE' %}label-warning
{% else %}label-danger{% endif %}">
<i class="fa {{ node.get_status_icon }}"></i> {{ node.get_status_display|upper }}
</span>
</div>
<div>
{% if node.enabled %}
<span class="label label-success">{% trans "Enabled" %}</span>
{% if node.schedule_enabled %}
<span class="label label-success">{% trans "Schedule enabled" %}</span>
{% else %}
<span class="label label-warning">{% trans "Schedule disabled" %}</span>
{% endif %}
{% else %}
<span class="label label-warning">{% trans "Disabled" %}</span>
{% endif %}
{% if node.online %}
<span class="label label-success">{% trans "Online" %}</span>
{% else %}
<span class="label label-warning">{% trans "Offline" %}</span>
{% endif %}
</div>
</div>
<div class="col-md-10" id="node-detail-pane">
<div class="panel panel-default" id="node-detail-panel">
......
{% load i18n %}
<div class="btn-group">
<button type="button" class="btn {{ btn_size }} btn-warning nojs-dropdown-toogle dropdown-toggle" data-toggle="dropdown">Action
<i class="fa fa-caret-down"></i>
</button>
<ul class="dropdown-menu nojs-dropdown-toogle" role="menu">
<li>
<a href="#" class="node-details-rename-button">
<i class="fa fa-pencil"></i> {% trans "Rename" %}
</a>
</li>
<li>
<a data-node-pk="{{ record.pk }}" class="real-link node-flush" href="{% url "dashboard.views.flush-node" pk=record.pk %}">
<i class="fa fa-cloud-upload"></i> {% trans "Flush" %}
</a>
</li>
<li>
<a style={% if record.enabled %}"display:none"{% else %}"display:block"{% endif %} data-node-pk="{{ record.pk }}" class="real-link node-enable" href="{% url "dashboard.views.status-node" pk=record.pk %}?next={{ request.path }}">
<i class="fa fa-check"></i> {% trans "Enable" %}
</a>
</li>
<li>
<a style={% if record.enabled %}"display:block"{% else %}"display:none"{% endif %} data-node-pk="{{ record.pk }}" class="real-link node-enable" href="{% url "dashboard.views.status-node" pk=record.pk %}?next={{ request.path }}">
<i class="fa fa-times"></i> {% trans "Disable" %}
</a>
</li>
<li>
<a data-node-pk="{{ record.pk }}" class="real-link node-delete" href="{% url "dashboard.views.delete-node" pk=record.pk %}?next={{ request.path }}">
<i class="fa fa-trash-o"></i> {% trans "Delete" %}
</a>
</li>
</ul>
</div>
......@@ -25,7 +25,7 @@ from .views import (
GroupDetailView, GroupList, IndexView,
InstanceActivityDetail, LeaseCreate, LeaseDelete, LeaseDetail,
MyPreferencesView, NodeAddTraitView, NodeCreate, NodeDelete,
NodeDetailView, NodeFlushView, NodeList, NodeStatus,
NodeDetailView, NodeList, NodeStatus,
NotificationView, PortDelete, TemplateAclUpdateView, TemplateCreate,
TemplateDelete, TemplateDetail, TemplateList, TransferOwnershipConfirmView,
TransferOwnershipView, vm_activity, VmCreate, VmDetailView,
......@@ -48,6 +48,8 @@ from .views import (
ClientCheck, TokenLogin,
VmGraphView, NodeGraphView, NodeListGraphView,
)
from .views.vm import vm_ops, vm_mass_ops
from .views.node import node_ops
autocomplete_light.autodiscover()
......@@ -75,8 +77,6 @@ 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/', 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(),
......@@ -111,8 +111,6 @@ urlpatterns = patterns(
name="dashboard.views.delete-node"),
url(r'^node/status/(?P<pk>\d+)/$', NodeStatus.as_view(),
name="dashboard.views.status-node"),
url(r'^node/flush/(?P<pk>\d+)/$', NodeFlushView.as_view(),
name="dashboard.views.flush-node"),
url(r'^node/create/$', NodeCreate.as_view(),
name='dashboard.views.node-create'),
......@@ -215,3 +213,21 @@ urlpatterns = patterns(
url(r'^token-login/(?P<token>.*)/$', TokenLogin.as_view(),
name="dashboard.views.token-login"),
)
urlpatterns += patterns(
'',
*(url(r'^vm/(?P<pk>\d+)/op/%s/$' % op, v.as_view(), name=v.get_urlname())
for op, v in vm_ops.iteritems())
)
urlpatterns += patterns(
'',
*(url(r'^vm/mass_op/%s/$' % op, v.as_view(), name=v.get_urlname())
for op, v in vm_mass_ops.iteritems())
)
urlpatterns += patterns(
'',
*(url(r'^node/(?P<pk>\d+)/op/%s/$' % op, v.as_view(), name=v.get_urlname())
for op, v in node_ops.iteritems())
)
......@@ -17,6 +17,7 @@
from __future__ import unicode_literals, absolute_import
import json
from collections import OrderedDict
from django.conf import settings
from django.contrib import messages
......@@ -37,7 +38,39 @@ from vm.models import Node, NodeActivity, Trait
from ..forms import TraitForm, HostForm, NodeForm
from ..tables import NodeListTable
from .util import GraphMixin
from .util import AjaxOperationMixin, OperationView, GraphMixin
def get_operations(instance, user):
ops = []
for k, v in node_ops.iteritems():
try:
op = v.get_op_by_object(instance)
op.check_auth(user)
op.check_precond()
except Exception:
ops.append(v.bind_to_object(instance, disabled=True))
else:
ops.append(v.bind_to_object(instance))
return ops
class NodeOperationView(AjaxOperationMixin, OperationView):
model = Node
context_object_name = 'node' # much simpler to mock object
node_ops = OrderedDict([
('activate', NodeOperationView.factory(
op='activate', icon='play-circle', effect='success')),
('passivate', NodeOperationView.factory(
op='passivate', icon='play-circle-o', effect='info')),
('disable', NodeOperationView.factory(
op='disable', icon='times-circle-o', effect='danger')),
('flush', NodeOperationView.factory(
op='flush', icon='paint-brush', effect='danger')),
])
class NodeDetailView(LoginRequiredMixin, SuperuserRequiredMixin,
......@@ -54,6 +87,8 @@ class NodeDetailView(LoginRequiredMixin, SuperuserRequiredMixin,
na = NodeActivity.objects.filter(
node=self.object, parent=None
).order_by('-started').select_related()
context['ops'] = get_operations(self.object, self.request.user)
context['op'] = {i.op: i for i in context['ops']}
context['activities'] = na
context['trait_form'] = form
context['graphite_enabled'] = (
......@@ -316,39 +351,3 @@ class NodeStatus(LoginRequiredMixin, SuperuserRequiredMixin, DetailView):
else:
messages.success(request, success_message)
return redirect(self.get_success_url())
class NodeFlushView(LoginRequiredMixin, SuperuserRequiredMixin, DetailView):
template_name = "dashboard/confirm/node-flush.html"
model = Node
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-node-flush.html']
else:
return ['dashboard/confirm/node-flush.html']
def get_success_url(self):
next = self.request.GET.get('next')
if next:
return next
else:
return reverse_lazy("dashboard.views.node-detail",
kwargs={'pk': self.object.pk})
def get_context_data(self, **kwargs):
context = super(NodeFlushView, self).get_context_data(**kwargs)
return context
def post(self, request, *args, **kwargs):
if request.POST.get('flush') is not None:
return self.__flush(request)
return redirect(reverse_lazy("dashboard.views.node-detail",
kwargs={'pk': self.get_object().pk}))
def __flush(self, request):
self.object = self.get_object()
self.object.flush.async(user=request.user)
success_message = _("Node successfully flushed.")
messages.success(request, success_message)
return redirect(self.get_success_url())
......@@ -395,7 +395,7 @@ class VmMigrateView(VmOperationView):
def get_context_data(self, **kwargs):
ctx = super(VmMigrateView, self).get_context_data(**kwargs)
ctx['nodes'] = [n for n in Node.objects.filter(enabled=True)
if n.state == "ONLINE"]
if n.online]
return ctx
def post(self, request, extra=None, *args, **kwargs):
......
# Copyright 2014 Budapest University of Technology and Economics (BME IK)
#
# This file is part of CIRCLE Cloud.
#
# CIRCLE is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# CIRCLE is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along
# with CIRCLE. If not, see <http://www.gnu.org/licenses/>.
from django.conf.urls import patterns, url
from ..views import vm_ops, vm_mass_ops
urlpatterns = patterns(
'',
*(url(r'^(?P<pk>\d+)/op/%s/$' % op, v.as_view(), name=v.get_urlname())
for op, v in vm_ops.iteritems())
)
urlpatterns += patterns(
'',
*(url(r'^mass_op/%s/$' % op, v.as_view(), name=v.get_urlname())
for op, v in vm_mass_ops.iteritems())
)
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -6,7 +6,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2014-09-16 12:15+0200\n"
"POT-Creation-Date: 2014-09-24 12:19+0200\n"
"PO-Revision-Date: 2014-09-03 12:51+0200\n"
"Last-Translator: Mate Ory <ory.mate@ik.bme.hu>\n"
"Language-Team: Hungarian <cloud@ik.bme.hu>\n"
......@@ -38,9 +38,9 @@ msgstr ""
msgid "Select an option to proceed!"
msgstr "Válasszon a folytatáshoz."
#: dashboard/static/dashboard/dashboard.js:258
#: dashboard/static/dashboard/dashboard.js:306
#: dashboard/static/dashboard/dashboard.js:316
#: dashboard/static/dashboard/dashboard.js:259
#: dashboard/static/dashboard/dashboard.js:307
#: dashboard/static/dashboard/dashboard.js:317
#: static_collected/all.047675ebf594.js:3633
#: static_collected/all.047675ebf594.js:3681
#: static_collected/all.047675ebf594.js:3691
......
......@@ -55,7 +55,7 @@ def select_node(instance, nodes):
'''
# check required traits
nodes = [n for n in nodes
if n.enabled and n.online
if n.schedule_enabled and n.online
and has_traits(instance.req_traits.all(), n)]
if not nodes:
logger.warning('select_node: no usable node for %s', unicode(instance))
......
......@@ -27,7 +27,7 @@ from django.db.models import (
FloatField, permalink, Sum
)
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _, ugettext_noop
from django.utils.translation import ugettext_lazy as _
from celery.exceptions import TimeoutError
from model_utils.models import TimeStampedModel
......@@ -37,7 +37,7 @@ from common.models import method_cache, WorkerNotFound, HumanSortField
from common.operations import OperatedMixin
from firewall.models import Host
from ..tasks import vm_tasks
from .activity import node_activity, NodeActivity
from .activity import NodeActivity
from .common import Trait
......@@ -72,6 +72,11 @@ class Node(OperatedMixin, TimeStampedModel):
enabled = BooleanField(verbose_name=_('enabled'), default=False,
help_text=_('Indicates whether the node can '
'be used for hosting.'))
schedule_enabled = BooleanField(verbose_name=_('schedule enabled'),
default=False, help_text=_(
'Indicates whether a vm can be '
'automatically scheduled to this '
'node.'))
traits = ManyToManyField(Trait, blank=True,
help_text=_("Declared traits."),
verbose_name=_('traits'))
......@@ -130,46 +135,30 @@ class Node(OperatedMixin, TimeStampedModel):
warn('Use Node.info["core_num"]', DeprecationWarning)
return self.info['core_num']
STATES = {False: {False: ('OFFLINE', _('offline')),
True: ('DISABLED', _('disabled'))},
True: {False: ('MISSING', _('missing')),
True: ('ONLINE', _('online'))}}
STATES = {None: ({True: ('MISSING', _('missing')),
False: ('OFFLINE', _('offline'))}),
False: {False: ('DISABLED', _('disabled'))},
True: {False: ('PASSIVE', _('passive')),
True: ('ACTIVE', _('active'))}}
def get_state(self):
"""The state combined of online and enabled attributes.
def _get_state(self):
"""The state tuple based on online and enabled attributes.
"""
return self.STATES[self.enabled][self.online][0]
state = property(get_state)
if self.online:
return self.STATES[self.enabled][self.schedule_enabled]
else:
return self.STATES[None][self.enabled]
def get_status_display(self):
return self.STATES[self.enabled][self.online][1]
return self._get_state()[1]
def disable(self, user=None, base_activity=None):
''' Disable the node.'''
if self.enabled:
if base_activity:
act_ctx = base_activity.sub_activity(
'disable', readable_name=ugettext_noop("disable node"))
else:
act_ctx = node_activity(
'disable', node=self, user=user,
readable_name=ugettext_noop("disable node"))
with act_ctx:
self.enabled = False
self.save()
def get_state(self):
return self._get_state()[0]
state = property(get_state)
def enable(self, user=None, base_activity=None):
''' Enable the node. '''
if self.enabled is not True:
if base_activity:
act_ctx = base_activity.sub_activity('enable')
else:
act_ctx = node_activity('enable', node=self, user=user)
with act_ctx:
self.enabled = True
self.save()
self.get_info(invalidate_cache=True)
raise NotImplementedError("Use activate or passivate instead.")
@property
@node_available
......@@ -314,10 +303,11 @@ class Node(OperatedMixin, TimeStampedModel):
def get_status_icon(self):
return {
'OFFLINE': 'fa-minus-circle',
'DISABLED': 'fa-moon-o',
'DISABLED': 'fa-times-circle-o',
'OFFLINE': 'fa-times-circle',
'MISSING': 'fa-warning',
'ONLINE': 'fa-play-circle'}.get(self.get_state(),
'PASSIVE': 'fa-play-circle-o',
'ACTIVE': 'fa-play-circle'}.get(self.get_state(),
'fa-question-circle')
def get_status_label(self):
......@@ -385,6 +375,11 @@ class Node(OperatedMixin, TimeStampedModel):
def get_absolute_url(self):
return ('dashboard.views.node-detail', None, {'pk': self.id})
def save(self, *args, **kwargs):
if not self.enabled:
self.schedule_enabled = False
super(Node, self).save(*args, **kwargs)
@property
def metric_prefix(self):
return 'circle.%s' % self.host.hostname
......@@ -208,8 +208,10 @@ class NodeTestCase(TestCase):
node = Mock(spec=Node)
node.online = True
node.enabled = True
node.schedule_enabled = True
node.STATES = Node.STATES
self.assertEqual(Node.get_state(node), "ONLINE")
node._get_state = lambda: Node._get_state(node)
self.assertEqual(Node.get_state(node), "ACTIVE")
assert isinstance(Node.get_status_display(node), _("x").__class__)
......@@ -351,51 +353,30 @@ class InstanceActivityTestCase(TestCase):
self.assertTrue(InstanceActivity.is_abortable_for(iaobj, su))
def test_disable_enabled(self):
node = MagicMock(spec=Node, enabled=True)
with patch('vm.models.node.node_activity') as nac:
na = MagicMock()
nac.return_value = na
na.__enter__.return_value = MagicMock()
Node.disable(node)
self.assertFalse(node.enabled)
node.save.assert_called_once()
na.assert_called()
node = MagicMock(spec=Node, enabled=True, online=True)
node.instance_set.exists.return_value = False
Node._ops['disable'](node).check_precond()
def test_disable_disabled(self):
node = MagicMock(spec=Node, enabled=False)
with patch('vm.models.node.node_activity') as nac:
na = MagicMock()
na.__enter__.side_effect = AssertionError
nac.return_value = na
Node.disable(node)
self.assertFalse(node.enabled)
def test_disable_enabled_sub(self):
node = MagicMock(spec=Node, enabled=True)
act = MagicMock()
subact = MagicMock()
act.sub_activity.return_value = subact
Node.disable(node, base_activity=act)
self.assertFalse(node.enabled)
subact.__enter__.assert_called()
with self.assertRaises(Exception):
Node._ops['disable'](node).check_precond()
def test_flush(self):
insts = [MagicMock(spec=Instance, migrate=MagicMock()),
MagicMock(spec=Instance, migrate=MagicMock())]
insts[0].name = insts[1].name = "x"
node = MagicMock(spec=Node, enabled=True)
node = MagicMock(spec=Node, enabled=True, schedule_enabled=True)
node.instance_set.all.return_value = insts
user = MagicMock(spec=User)
user.is_superuser = MagicMock(return_value=True)
flush_op = FlushOperation(node)
with patch.object(FlushOperation, 'create_activity') as create_act:
act = create_act.return_value = MagicMock()
flush_op(user=user)
with patch.object(FlushOperation, 'create_activity') as create_act, \
patch.object(
Node._ops['passivate'], 'create_activity') as create_act2:
FlushOperation(node)(user=user)
node.schedule_enabled = True
create_act.assert_called()
node.disable.assert_called_with(user, act)
create_act2.assert_called()
for i in insts:
i.migrate.assert_called()
user.is_superuser.assert_called()
......@@ -404,17 +385,13 @@ class InstanceActivityTestCase(TestCase):
insts = [MagicMock(spec=Instance, migrate=MagicMock()),
MagicMock(spec=Instance, migrate=MagicMock())]
insts[0].name = insts[1].name = "x"
node = MagicMock(spec=Node, enabled=False)
node = MagicMock(spec=Node, enabled=False, schedule_enabled=False)
node.instance_set.all.return_value = insts
flush_op = FlushOperation(node)
with patch.object(FlushOperation, 'create_activity') as create_act:
act = create_act.return_value = MagicMock()
create_act.return_value = MagicMock()
flush_op(system=True)
create_act.assert_called()
node.disable.assert_called_with(None, act)
# ^ should be called, but real method no-ops if disabled
for i in insts:
i.migrate.assert_called()
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 sign in to comment