Commit 3d8b3e44 by Bach Dániel

Merge branch issue-107

Fix VM scalability problems #107

 template based view instead of tables2
 revise columns
 add i18n to template
 cache state to Instance.state on activity saves
 tests (92% coverage)
parents e60ac87d 50a6f530
...@@ -1352,7 +1352,7 @@ ...@@ -1352,7 +1352,7 @@
"pk": 1, "pk": 1,
"model": "vm.instance", "model": "vm.instance",
"fields": { "fields": {
"destroyed": null, "destroyed_at": null,
"disks": [ "disks": [
1 1
], ],
...@@ -1383,7 +1383,7 @@ ...@@ -1383,7 +1383,7 @@
"pk": 12, "pk": 12,
"model": "vm.instance", "model": "vm.instance",
"fields": { "fields": {
"destroyed": null, "destroyed_at": null,
"disks": [], "disks": [],
"boot_menu": false, "boot_menu": false,
"owner": 1, "owner": 1,
......
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
{% elif node.state == 'MISSING' %}label-danger {% elif node.state == 'MISSING' %}label-danger
{% elif node.state == 'DISABLED' %}label-warning {% elif node.state == 'DISABLED' %}label-warning
{% elif node.state == 'OFFLINE' %}label-warning {% elif node.state == 'OFFLINE' %}label-warning
{% endif %}">{{ node.state }}</span> {% endif %}">{{ node.get_status_display|upper }}</span>
<div class="btn-group"> <div class="btn-group">
<button type="button" class="btn {{ btn_size }} btn-warning nojs-dropdown-toogle dropdown-toggle" data-toggle="dropdown">Action <i class="icon-caret-down"></i></button> <button type="button" class="btn {{ btn_size }} btn-warning nojs-dropdown-toogle dropdown-toggle" data-toggle="dropdown">Action <i class="icon-caret-down"></i></button>
<ul class="dropdown-menu nojs-dropdown-toogle" role="menu"> <ul class="dropdown-menu nojs-dropdown-toogle" role="menu">
......
{% extends "dashboard/base.html" %} {% extends "dashboard/base.html" %}
{% load i18n %} {% load i18n %}
{% load render_table from django_tables2 %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h3 class="no-margin"><i class="icon-desktop"></i> Your virtual machines</h3> <h3 class="no-margin"><i class="icon-desktop"></i>{% trans "Virtual machines" %}</h3>
</div> </div>
<div class="pull-right" style="max-width: 250px; margin-top: 15px; margin-right: 15px;"> <div class="pull-right" style="max-width: 250px; margin-top: 15px; margin-right: 15px;">
<form action="" method="GET" class="input-group"> <form action="" method="GET" class="input-group">
<input type="text" name="s"{% if request.GET.s %} value="{{ request.GET.s }}"{% endif %} class="form-control input-tags" placeholder="Search..." /> <input type="text" name="s"{% if request.GET.s %} value="{{ request.GET.s }}"{% endif %} class="form-control input-tags" placeholder="{% trans "Search..."%}" />
<div class="input-group-btn"> <div class="input-group-btn">
<button type="submit" class="form-control btn btn-primary input-tags" title="search"><i class="icon-search"></i></button> <button type="submit" class="form-control btn btn-primary input-tags" title="search"><i class="icon-search"></i></button>
</div> </div>
...@@ -19,31 +18,47 @@ ...@@ -19,31 +18,47 @@
</div> </div>
<div class="panel-body vm-list-group-control"> <div class="panel-body vm-list-group-control">
<p> <p>
<strong>Group actions</strong> <strong>{% trans "Group actions" %}</strong>
<button id="vm-list-group-select-all" class="btn btn-info btn-xs">Select all</button> <button id="vm-list-group-select-all" class="btn btn-info btn-xs">{% trans "Select all" %}</button>
<a class="btn btn-default btn-xs" id="vm-list-group-migrate" disabled><i class="icon-truck"></i> Migrate</a> <a class="btn btn-default btn-xs" id="vm-list-group-migrate" disabled><i class="icon-truck"></i> {% trans "Migrate" %}</a>
<a disabled href="#" class="btn btn-default btn-xs"><i class="icon-refresh"></i> Reboot</a> <a disabled href="#" class="btn btn-default btn-xs"><i class="icon-refresh"></i> {% trans "Reboot" %}</a>
<a disabled href="#" class="btn btn-default btn-xs"><i class="icon-off"></i> Shutdown</a> <a disabled href="#" class="btn btn-default btn-xs"><i class="icon-off"></i> {% trans "Shutdown" %}</a>
<a id="vm-list-group-delete" disabled href="#" class="btn btn-danger btn-xs"><i class="icon-remove"></i> Discard</a> <a id="vm-list-group-delete" disabled href="#" class="btn btn-danger btn-xs"><i class="icon-remove"></i> {% trans "Destroy" %}</a>
</p> </p>
</div> </div>
<div class="panel-body"> <div class="panel-body">
{% render_table table %} <table class="table table-bordered table-striped table-hover vm-list-table">
</div> <thead><tr>
<th class="orderable pk sortable vm-list-table-thin"><a href="?sort=pk">{% trans "ID" %}</a></th>
<th class="name orderable sortable"><a href="?sort=name">{% trans "Name" %}</a></th>
<th>{% trans "State" %}</th>
<th class="orderable sortable"><a href="?sort=owner">{% trans "Owner" %}</a></th>
{% if user.is_superuser %}<th class="orderable sortable"><a href="?sort=node">{% trans "Node" %}</a></th>{% endif %}
</tr></thead><tbody>
{% for i in object_list %}
<tr class="{% cycle 'odd' 'even' %}">
<td class="pk"><div id="vm-{{i.pk}}">{{i.pk}}</div> </td>
<td class="name"><a class="real-link" href="{% url "dashboard.views.detail" i.pk %}">{{ i.name }}</a> </td>
<td class="state">{{ i.get_status_display }}</td>
<td>{{ i.owner }}</td>
{% if user.is_superuser %}<td>{{ i.node.name|default:"-" }}</td>{% endif %}
</tr>
{% empty %}
<tr><td colspan="4">{% trans "You have no virtual machines." %}</td></tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
<div class="alert alert-info">
Tip #1: you can select multiple vm instances while holding down the <strong>CTRL</strong> key!
</div> </div>
<div class="alert alert-info"> <div class="alert alert-info">
Tip #2: if you want to select multiple instances by one click select an instance then hold down <strong>SHIFT</strong> key and select another one! {% trans "You can select multiple vm instances while holding down the <strong>CTRL</strong> key." %}
{% trans "If you want to select multiple instances by one click select an instance then hold down <strong>SHIFT</strong> key and select another one!" %}
</div> </div>
<style> <style>
.popover { .popover {
max-width: 600px; max-width: 600px;
......
...@@ -20,7 +20,7 @@ from django.shortcuts import redirect, render, get_object_or_404 ...@@ -20,7 +20,7 @@ from django.shortcuts import redirect, render, get_object_or_404
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django.views.generic import (TemplateView, DetailView, View, DeleteView, from django.views.generic import (TemplateView, DetailView, View, DeleteView,
UpdateView, CreateView) UpdateView, CreateView, ListView)
from django.contrib import messages from django.contrib import messages
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.template.defaultfilters import title as title_filter from django.template.defaultfilters import title as title_filter
...@@ -37,7 +37,7 @@ from .forms import ( ...@@ -37,7 +37,7 @@ from .forms import (
CircleAuthenticationForm, DiskAddForm, HostForm, LeaseForm, NodeForm, CircleAuthenticationForm, DiskAddForm, HostForm, LeaseForm, NodeForm,
TemplateForm, TraitForm, VmCustomizeForm, TemplateForm, TraitForm, VmCustomizeForm,
) )
from .tables import (VmListTable, NodeListTable, NodeVmListTable, from .tables import (NodeListTable, NodeVmListTable,
TemplateListTable, LeaseListTable, GroupListTable,) TemplateListTable, LeaseListTable, GroupListTable,)
from vm.models import ( from vm.models import (
Instance, instance_activity, InstanceActivity, InstanceTemplate, Interface, Instance, instance_activity, InstanceActivity, InstanceTemplate, Interface,
...@@ -89,7 +89,7 @@ class IndexView(LoginRequiredMixin, TemplateView): ...@@ -89,7 +89,7 @@ class IndexView(LoginRequiredMixin, TemplateView):
favs = Instance.objects.filter(favourite__user=self.request.user) favs = Instance.objects.filter(favourite__user=self.request.user)
instances = Instance.get_objects_with_level( instances = Instance.get_objects_with_level(
'user', user).filter(destroyed=None) 'user', user).filter(destroyed_at=None)
display = list(favs) + list(set(instances) - set(favs)) display = list(favs) + list(set(instances) - set(favs))
for d in display: for d in display:
d.fav = True if d in favs else False d.fav = True if d in favs else False
...@@ -118,13 +118,13 @@ class IndexView(LoginRequiredMixin, TemplateView): ...@@ -118,13 +118,13 @@ class IndexView(LoginRequiredMixin, TemplateView):
} }
}) })
running = [i for i in instances if i.state == 'RUNNING'] running = instances.filter(status='RUNNING')
stopped = [i for i in instances if i.state not in ['RUNNING', stopped = instances.exclude(status__in=('RUNNING', 'NOSTATE'))
'NOSTATE']]
context.update({ context.update({
'running_vms': running, 'running_vms': running[:20],
'running_vm_num': len(running), 'running_vm_num': running.count(),
'stopped_vm_num': len(stopped) 'stopped_vm_num': stopped.count()
}) })
context['templates'] = InstanceTemplate.objects.all()[:5] context['templates'] = InstanceTemplate.objects.all()[:5]
...@@ -198,10 +198,11 @@ class VmDetailView(CheckedDetailView): ...@@ -198,10 +198,11 @@ class VmDetailView(CheckedDetailView):
}) })
# activity data # activity data
ia = InstanceActivity.objects.filter( context['activities'] = (
instance=self.object, parent=None InstanceActivity.objects.filter(
).order_by('-started').select_related() instance=self.object, parent=None).
context['activities'] = ia order_by('-started').
select_related('user').prefetch_related('children'))
context['vlans'] = Vlan.get_objects_with_level( context['vlans'] = Vlan.get_objects_with_level(
'user', self.request.user 'user', self.request.user
...@@ -884,11 +885,8 @@ class TemplateDelete(LoginRequiredMixin, DeleteView): ...@@ -884,11 +885,8 @@ class TemplateDelete(LoginRequiredMixin, DeleteView):
return HttpResponseRedirect(success_url) return HttpResponseRedirect(success_url)
class VmList(LoginRequiredMixin, SingleTableView): class VmList(LoginRequiredMixin, ListView):
template_name = "dashboard/vm-list.html" template_name = "dashboard/vm-list.html"
table_class = VmListTable
table_pagination = False
model = Instance
def get(self, *args, **kwargs): def get(self, *args, **kwargs):
if self.request.is_ajax(): if self.request.is_ajax():
...@@ -896,7 +894,7 @@ class VmList(LoginRequiredMixin, SingleTableView): ...@@ -896,7 +894,7 @@ class VmList(LoginRequiredMixin, SingleTableView):
favourite__user=self.request.user).values_list('pk', flat=True) favourite__user=self.request.user).values_list('pk', flat=True)
instances = Instance.get_objects_with_level( instances = Instance.get_objects_with_level(
'user', self.request.user).filter( 'user', self.request.user).filter(
destroyed=None).all() destroyed_at=None).all()
instances = [{ instances = [{
'pk': i.pk, 'pk': i.pk,
'name': i.name, 'name': i.name,
...@@ -913,11 +911,11 @@ class VmList(LoginRequiredMixin, SingleTableView): ...@@ -913,11 +911,11 @@ class VmList(LoginRequiredMixin, SingleTableView):
logger.debug('VmList.get_queryset() called. User: %s', logger.debug('VmList.get_queryset() called. User: %s',
unicode(self.request.user)) unicode(self.request.user))
queryset = Instance.get_objects_with_level( queryset = Instance.get_objects_with_level(
'user', self.request.user).filter(destroyed=None) 'user', self.request.user).filter(destroyed_at=None)
s = self.request.GET.get("s") s = self.request.GET.get("s")
if s: if s:
queryset = queryset.filter(name__icontains=s) queryset = queryset.filter(name__icontains=s)
return queryset return queryset.select_related('owner', 'node')
class NodeList(LoginRequiredMixin, SuperuserRequiredMixin, SingleTableView): class NodeList(LoginRequiredMixin, SuperuserRequiredMixin, SingleTableView):
......
...@@ -162,7 +162,7 @@ class DomainDetail(LoginRequiredMixin, SuperuserRequiredMixin, ...@@ -162,7 +162,7 @@ class DomainDetail(LoginRequiredMixin, SuperuserRequiredMixin,
domain=self.object, domain=self.object,
host__in=Host.objects.filter( host__in=Host.objects.filter(
interface__in=Interface.objects.filter( interface__in=Interface.objects.filter(
instance__destroyed=None) instance__destroyed_at=None)
) )
) )
context['record_list'] = SmallRecordTable(q) context['record_list'] = SmallRecordTable(q)
...@@ -618,7 +618,7 @@ class VlanDetail(LoginRequiredMixin, SuperuserRequiredMixin, ...@@ -618,7 +618,7 @@ class VlanDetail(LoginRequiredMixin, SuperuserRequiredMixin,
context = super(VlanDetail, self).get_context_data(**kwargs) context = super(VlanDetail, self).get_context_data(**kwargs)
q = Host.objects.filter(interface__in=Interface.objects.filter( q = Host.objects.filter(interface__in=Interface.objects.filter(
vlan=self.object, instance__destroyed=None vlan=self.object, instance__destroyed_at=None
)) ))
context['host_list'] = SmallHostTable(q) context['host_list'] = SmallHostTable(q)
......
...@@ -79,6 +79,11 @@ class InstanceActivity(ActivityModel): ...@@ -79,6 +79,11 @@ class InstanceActivity(ActivityModel):
act = self.create_sub(code_suffix, task_uuid) act = self.create_sub(code_suffix, task_uuid)
return activitycontextimpl(act, on_abort=on_abort, on_commit=on_commit) return activitycontextimpl(act, on_abort=on_abort, on_commit=on_commit)
def save(self, *args, **kwargs):
ret = super(InstanceActivity, self).save(*args, **kwargs)
self.instance._update_status()
return ret
@contextmanager @contextmanager
def instance_activity(code_suffix, instance, on_abort=None, on_commit=None, def instance_activity(code_suffix, instance, on_abort=None, on_commit=None,
......
...@@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals ...@@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
from datetime import timedelta from datetime import timedelta
from logging import getLogger from logging import getLogger
from importlib import import_module from importlib import import_module
from warnings import warn
import string import string
import django.conf import django.conf
...@@ -16,7 +17,8 @@ from django.utils import timezone ...@@ -16,7 +17,8 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from celery.exceptions import TimeLimitExceeded from celery.exceptions import TimeLimitExceeded
from model_utils.models import TimeStampedModel from model_utils import Choices
from model_utils.models import TimeStampedModel, StatusModel
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from acl.models import AclBase from acl.models import AclBase
...@@ -69,7 +71,7 @@ class InstanceActiveManager(Manager): ...@@ -69,7 +71,7 @@ class InstanceActiveManager(Manager):
def get_query_set(self): def get_query_set(self):
return super(InstanceActiveManager, return super(InstanceActiveManager,
self).get_query_set().filter(destroyed=None) self).get_query_set().filter(destroyed_at=None)
class VirtualMachineDescModel(BaseResourceConfigModel): class VirtualMachineDescModel(BaseResourceConfigModel):
...@@ -161,7 +163,8 @@ class InstanceTemplate(AclBase, VirtualMachineDescModel, TimeStampedModel): ...@@ -161,7 +163,8 @@ class InstanceTemplate(AclBase, VirtualMachineDescModel, TimeStampedModel):
return ('dashboard.views.template-detail', None, {'pk': self.pk}) return ('dashboard.views.template-detail', None, {'pk': self.pk})
class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel): class Instance(AclBase, VirtualMachineDescModel, StatusModel,
TimeStampedModel):
"""Virtual machine instance. """Virtual machine instance.
""" """
...@@ -170,6 +173,15 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel): ...@@ -170,6 +173,15 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel):
('operator', _('operator')), # console, networking, change state ('operator', _('operator')), # console, networking, change state
('owner', _('owner')), # superuser, can delete, delegate perms ('owner', _('owner')), # superuser, can delete, delegate perms
) )
STATUS = Choices(
('NOSTATE', _('no state')),
('RUNNING', _('running')),
('STOPPED', _('stopped')),
('SUSPENDED', _('suspended')),
('ERROR', _('error')),
('PENDING', _('pending')),
('DESTROYED', _('destroyed')),
)
name = CharField(blank=True, max_length=100, verbose_name=_('name'), name = CharField(blank=True, max_length=100, verbose_name=_('name'),
help_text=_("Human readable name of instance.")) help_text=_("Human readable name of instance."))
description = TextField(blank=True, verbose_name=_('description')) description = TextField(blank=True, verbose_name=_('description'))
...@@ -202,7 +214,7 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel): ...@@ -202,7 +214,7 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel):
help_text=_("TCP port where VNC console listens."), help_text=_("TCP port where VNC console listens."),
unique=True, verbose_name=_('vnc_port')) unique=True, verbose_name=_('vnc_port'))
owner = ForeignKey(User) owner = ForeignKey(User)
destroyed = DateTimeField(blank=True, null=True, destroyed_at = DateTimeField(blank=True, null=True,
help_text=_("The virtual machine's time of " help_text=_("The virtual machine's time of "
"destruction.")) "destruction."))
objects = Manager() objects = Manager()
...@@ -258,7 +270,22 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel): ...@@ -258,7 +270,22 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel):
@property @property
def state(self): def state(self):
"""State of the virtual machine instance. warn('Use Instance.status (or get_status_display) instead.',
DeprecationWarning)
return self.status
def _update_status(self):
"""Set the proper status of the instance to Instance.status.
"""
old = self.status
self.status = self._compute_status()
if old != self.status:
logger.info('Status of Instance#%d changed to %s',
self.pk, self.status)
self.save()
def _compute_status(self):
"""Return the proper status of the instance based on activities.
""" """
# check special cases # check special cases
if self.activity_log.filter(activity_code__endswith='migrate', if self.activity_log.filter(activity_code__endswith='migrate',
...@@ -745,7 +772,7 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel): ...@@ -745,7 +772,7 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel):
asynchronously. asynchronously.
:type task_uuid: str :type task_uuid: str
""" """
if self.destroyed: if self.destroyed_at:
raise self.InstanceDestroyedError(self) raise self.InstanceDestroyedError(self)
def __on_commit(activity): def __on_commit(activity):
...@@ -893,7 +920,7 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel): ...@@ -893,7 +920,7 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel):
asynchronously. asynchronously.
:type task_uuid: str :type task_uuid: str
""" """
if self.destroyed: if self.destroyed_at:
return # already destroyed, nothing to do here return # already destroyed, nothing to do here
def __on_commit(activity): def __on_commit(activity):
...@@ -913,7 +940,7 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel): ...@@ -913,7 +940,7 @@ class Instance(AclBase, VirtualMachineDescModel, TimeStampedModel):
self.__cleanup_after_destroy_vm(act) self.__cleanup_after_destroy_vm(act)
self.destroyed = timezone.now() self.destroyed_at = timezone.now()
self.save() self.save()
def destroy_async(self, user=None): def destroy_async(self, user=None):
......
...@@ -59,7 +59,7 @@ class Interface(Model): ...@@ -59,7 +59,7 @@ class Interface(Model):
@property @property
def destroyed(self): def destroyed(self):
return self.instance.destroyed return self.instance.destroyed_at
@property @property
def mac(self): def mac(self):
......
...@@ -91,18 +91,20 @@ class Node(TimeStampedModel): ...@@ -91,18 +91,20 @@ class Node(TimeStampedModel):
num_cores = property(get_num_cores) num_cores = property(get_num_cores)
@property STATES = {False: {False: ('OFFLINE', _('offline')),
def state(self): True: ('DISABLED', _('disabled'))},
True: {False: ('MISSING', _('missing')),
True: ('ONLINE', _('online'))}}
def get_state(self):
"""The state combined of online and enabled attributes. """The state combined of online and enabled attributes.
""" """
if self.enabled and self.online: return self.STATES[self.enabled][self.online][0]
return 'ONLINE'
elif self.enabled and not self.online: state = property(get_state)
return 'MISSING'
elif not self.enabled and self.online: def get_status_display(self):
return 'DISABLED' return self.STATES[self.enabled][self.online][1]
else:
return 'OFFLINE'
def disable(self, user=None): def disable(self, user=None):
''' Disable the node.''' ''' Disable the node.'''
......
...@@ -25,7 +25,7 @@ def garbage_collector(timeout=15): ...@@ -25,7 +25,7 @@ def garbage_collector(timeout=15):
:type timeout: int :type timeout: int
""" """
now = timezone.now() now = timezone.now()
for i in Instance.objects.filter(destroyed=None).all(): for i in Instance.objects.filter(destroyed_at=None).all():
if i.time_of_delete and now > i.time_of_delete: if i.time_of_delete and now > i.time_of_delete:
i.destroy_async() i.destroy_async()
logger.info("Expired instance %d destroyed.", i.pk) logger.info("Expired instance %d destroyed.", i.pk)
......
from datetime import datetime
from django.test import TestCase from django.test import TestCase
from django.utils.translation import ugettext_lazy as _
from mock import Mock, MagicMock, patch from mock import Mock, MagicMock, patch
from ..models.common import ( from ..models import (
Lease Lease, Node, Interface, Instance, InstanceTemplate,
)
from ..models.instance import (
find_unused_port, InstanceTemplate, Instance, ActivityInProgressError
)
from ..models.network import (
Interface
) )
from ..models.instance import find_unused_port, ActivityInProgressError
class PortFinderTestCase(TestCase): class PortFinderTestCase(TestCase):
...@@ -50,6 +47,27 @@ class InstanceTestCase(TestCase): ...@@ -50,6 +47,27 @@ class InstanceTestCase(TestCase):
self.assertEquals(inst.node, node) self.assertEquals(inst.node, node)
self.assertEquals(inst.vnc_port, port) self.assertEquals(inst.vnc_port, port)
def test_deploy_destroyed(self):
inst = Mock(destroyed_at=datetime.now(), spec=Instance,
InstanceDestroyedError=Instance.InstanceDestroyedError)
with self.assertRaises(Instance.InstanceDestroyedError):
Instance.deploy(inst)
def test_destroy_destroyed(self):
inst = Mock(destroyed_at=datetime.now(), spec=Instance)
Instance.destroy(inst)
self.assertFalse(inst.save.called)
def test_destroy_sets_destroyed(self):
inst = MagicMock(destroyed_at=None, spec=Instance)
inst.node = MagicMock(spec=Node)
inst.disks.all.return_value = []
with patch('vm.models.instance.instance_activity') as ia:
ia.return_value = MagicMock()
Instance.destroy(inst)
self.assertTrue(inst.destroyed_at)
inst.save.assert_called()
class InterfaceTestCase(TestCase): class InterfaceTestCase(TestCase):
...@@ -89,3 +107,14 @@ class LeaseTestCase(TestCase): ...@@ -89,3 +107,14 @@ class LeaseTestCase(TestCase):
l.suspend_interval = None l.suspend_interval = None
assert "never" in unicode(l) assert "never" in unicode(l)
class NodeTestCase(TestCase):
def test_state(self):
node = Mock(spec=Node)
node.online = True
node.enabled = True
node.STATES = Node.STATES
self.assertEqual(Node.get_state(node), "ONLINE")
assert isinstance(Node.get_status_display(node), _("").__class__)
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