Commit 536b16d6 by Kálmán Viktor

Merge remote-tracking branch 'origin/master' into feature-template-wizard

Conflicts:
	circle/dashboard/static/dashboard/dashboard.css
parents 7af77c17 be367e47
......@@ -18,6 +18,7 @@
from collections import deque
from contextlib import contextmanager
from hashlib import sha224
from itertools import chain, imap
from logging import getLogger
from time import time
......@@ -56,12 +57,52 @@ activity_context = contextmanager(activitycontextimpl)
activity_code_separator = '.'
def has_prefix(activity_code, *prefixes):
"""Determine whether the activity code has the specified prefix.
E.g.: has_prefix('foo.bar.buz', 'foo.bar') == True
has_prefix('foo.bar.buz', 'foo', 'bar') == True
has_prefix('foo.bar.buz', 'foo.bar', 'buz') == True
has_prefix('foo.bar.buz', 'foo', 'bar', 'buz') == True
has_prefix('foo.bar.buz', 'foo', 'buz') == False
"""
equal = lambda a, b: a == b
act_code_parts = split_activity_code(activity_code)
prefixes = chain(*imap(split_activity_code, prefixes))
return all(imap(equal, act_code_parts, prefixes))
def has_suffix(activity_code, *suffixes):
"""Determine whether the activity code has the specified suffix.
E.g.: has_suffix('foo.bar.buz', 'bar.buz') == True
has_suffix('foo.bar.buz', 'bar', 'buz') == True
has_suffix('foo.bar.buz', 'foo.bar', 'buz') == True
has_suffix('foo.bar.buz', 'foo', 'bar', 'buz') == True
has_suffix('foo.bar.buz', 'foo', 'buz') == False
"""
equal = lambda a, b: a == b
act_code_parts = split_activity_code(activity_code)
suffixes = list(chain(*imap(split_activity_code, suffixes)))
return all(imap(equal, reversed(act_code_parts), reversed(suffixes)))
def join_activity_code(*args):
"""Join the specified parts into an activity code.
:returns: Activity code string.
"""
return activity_code_separator.join(args)
def split_activity_code(activity_code):
"""Split the specified activity code into its parts.
:returns: A list of activity code parts.
"""
return activity_code.split(activity_code_separator)
class ActivityModel(TimeStampedModel):
activity_code = CharField(max_length=100, verbose_name=_('activity code'))
parent = ForeignKey('self', blank=True, null=True, related_name='children')
......
......@@ -15,9 +15,10 @@
# You should have received a copy of the GNU General Public License along
# with CIRCLE. If not, see <http://www.gnu.org/licenses/>.
from inspect import getargspec
from logging import getLogger
from .models import activity_context
from .models import activity_context, has_suffix
from django.core.exceptions import PermissionDenied
......@@ -31,6 +32,7 @@ class Operation(object):
async_queue = 'localhost.man'
required_perms = ()
do_not_call_in_templates = True
abortable = False
def __call__(self, **kwargs):
return self.call(**kwargs)
......@@ -46,23 +48,50 @@ class Operation(object):
def __prelude(self, kwargs):
"""This method contains the shared prelude of call and async.
"""
skip_auth_check = kwargs.setdefault('system', False)
user = kwargs.setdefault('user', None)
parent_activity = kwargs.pop('parent_activity', None)
defaults = {'parent_activity': None, 'system': False, 'user': None}
allargs = dict(defaults, **kwargs) # all arguments
auxargs = allargs.copy() # auxiliary (i.e. only for _operation) args
# NOTE: consumed items should be removed from auxargs, and no new items
# should be added to it
skip_auth_check = auxargs.pop('system')
user = auxargs.pop('user')
parent_activity = auxargs.pop('parent_activity')
# check for unexpected keyword arguments
argspec = getargspec(self._operation)
if argspec.keywords is None: # _operation doesn't take ** args
unexpected_kwargs = set(auxargs) - set(argspec.args)
if unexpected_kwargs:
raise TypeError("Operation got unexpected keyword arguments: "
"%s" % ", ".join(unexpected_kwargs))
if not skip_auth_check:
self.check_auth(user)
self.check_precond()
return self.create_activity(parent=parent_activity, user=user)
def _exec_op(self, activity, user, **kwargs):
activity = self.create_activity(parent=parent_activity, user=user)
return activity, allargs, auxargs
def _exec_op(self, allargs, auxargs):
"""Execute the operation inside the specified activity's context.
"""
with activity_context(activity, on_abort=self.on_abort,
# compile arguments for _operation
argspec = getargspec(self._operation)
if argspec.keywords is not None: # _operation takes ** args
arguments = allargs.copy()
else: # _operation doesn't take ** args
arguments = {k: v for (k, v) in allargs.iteritems()
if k in argspec.args}
arguments.update(auxargs)
with activity_context(allargs['activity'], on_abort=self.on_abort,
on_commit=self.on_commit):
return self._operation(activity=activity, user=user, **kwargs)
return self._operation(**arguments)
def _operation(self, activity, user, system, **kwargs):
def _operation(self, **kwargs):
"""This method is the operation's particular implementation.
Deriving classes should implement this method.
......@@ -82,12 +111,10 @@ class Operation(object):
logger.info("%s called asynchronously on %s with the following "
"parameters: %r", self.__class__.__name__, self.subject,
kwargs)
activity = self.__prelude(kwargs)
return self.async_operation.apply_async(args=(self.id,
self.subject.pk,
activity.pk),
kwargs=kwargs,
queue=self.async_queue)
activity, allargs, auxargs = self.__prelude(kwargs)
return self.async_operation.apply_async(
args=(self.id, self.subject.pk, activity.pk, allargs, auxargs, ),
queue=self.async_queue)
def call(self, **kwargs):
"""Execute the operation (synchronously).
......@@ -105,8 +132,9 @@ class Operation(object):
logger.info("%s called (synchronously) on %s with the following "
"parameters: %r", self.__class__.__name__, self.subject,
kwargs)
activity = self.__prelude(kwargs)
return self._exec_op(activity=activity, **kwargs)
activity, allargs, auxargs = self.__prelude(kwargs)
allargs['activity'] = activity
return self._exec_op(allargs, auxargs)
def check_precond(self):
pass
......@@ -160,6 +188,19 @@ class OperatedMixin(object):
else:
yield op
def get_operation_from_activity_code(self, activity_code):
"""Get an instance of the Operation corresponding to the specified
activity code.
:returns: A bound instance of an operation, or None if no matching
operation could be found.
"""
for op in getattr(self, operation_registry_name, {}).itervalues():
if has_suffix(activity_code, op.activity_code_suffix):
return op(self)
else:
return None
def register_operation(op_cls, op_id=None, target_cls=None):
"""Register the specified operation with the target class.
......
......@@ -75,3 +75,41 @@ class OperationTestCase(TestCase):
patch.object(Operation, 'create_activity'), \
patch.object(Operation, '_exec_op'):
op.call(system=True)
def test_no_exception_for_more_arguments_when_operation_takes_kwargs(self):
class KwargOp(Operation):
activity_code_suffix = 'test'
id = 'test'
def _operation(self, **kwargs):
pass
op = KwargOp(MagicMock())
with patch.object(KwargOp, 'create_activity'), \
patch.object(KwargOp, '_exec_op'):
op.call(system=True, foo=42)
def test_exception_for_unexpected_arguments(self):
class TestOp(Operation):
activity_code_suffix = 'test'
id = 'test'
def _operation(self):
pass
op = TestOp(MagicMock())
with patch.object(TestOp, 'create_activity'), \
patch.object(TestOp, '_exec_op'):
self.assertRaises(TypeError, op.call, system=True, foo=42)
def test_exception_for_missing_arguments(self):
class TestOp(Operation):
activity_code_suffix = 'test'
id = 'test'
def _operation(self, foo):
pass
op = TestOp(MagicMock())
with patch.object(TestOp, 'create_activity'):
self.assertRaises(TypeError, op.call, system=True)
......@@ -23,6 +23,46 @@ html {
padding-right: 15px;
}
/* values for 45px tall navbar */
.navbar {
min-height: 45px;
}
.navbar-brand {
height: 45px;
padding: 12.5px 12.5px;
}
.navbar-toggle {
margin-top: 5.5px;
margin-bottom: 5.5px;
}
.navbar-form {
margin-top: 5.5px;
margin-bottom: 5.5px;
}
.navbar-btn {
margin-top: 5.5px;
margin-bottom: 5.5px;
}
.navbar-btn.btn-sm {
margin-top: 7.5px;
margin-bottom: 7.5px;
}
.navbar-btn.btn-xs {
margin-top: 11.5px;
margin-bottom: 11.5px;
}
.navbar-text {
margin-top: 12.5px;
margin-bottom: 12.5px;
}
/* --- */
/* Responsive: Portrait tablets and up */
@media screen and (min-width: 768px) {
/* Let the jumbotron breathe */
......@@ -33,6 +73,12 @@ html {
.body-content {
padding: 0;
}
.navbar-nav > li > a {
padding-top: 12.5px;
padding-bottom: 12.5px;
}
}
.no-margin {
margin: 0!important;
......@@ -552,3 +598,12 @@ footer a, footer a:hover, footer a:visited {
#ops {
padding: 15px 0 15px 15px;
}
#vm-access-table th:last-child, #vm-access-table td:last-child,
#template-access-table th:last-child, #template-access-table td:last-child {
text-align: center;
}
#notifications-button {
margin: 0;
}
......@@ -183,6 +183,7 @@ $(function() {
$("#vm-details-h1-name").hide();
$("#vm-details-rename").css('display', 'inline');
$("#vm-details-rename-name").focus();
return false;
});
/* rename in home tab */
......@@ -190,6 +191,7 @@ $(function() {
$(".vm-details-home-edit-name-click").hide();
$("#vm-details-home-rename").show();
$("input", $("#vm-details-home-rename")).focus();
return false;
});
/* rename ajax */
......@@ -219,6 +221,11 @@ $(function() {
$(".vm-details-home-edit-description-click").click(function() {
$(".vm-details-home-edit-description-click").hide();
$("#vm-details-home-description").show();
var ta = $("#vm-details-home-description textarea");
var tmp = ta.val();
ta.val("");
ta.focus();
ta.val(tmp)
return false;
});
......
......@@ -26,7 +26,9 @@
<body>
<div class="navbar navbar-inverse navbar-fixed-top">
<div class="navbar-header">
<a class="navbar-brand" href="{% url "dashboard.index" %}">CIRCLE</a>
<a class="navbar-brand" href="{% url "dashboard.index" %}" style="padding: 10px 15px;">
<img src="{{ STATIC_URL}}dashboard/img/logo.png" style="height: 25px;"/>
</a>
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
......
......@@ -8,7 +8,7 @@
{% block content %}
<div class="row">
<div class="col-md-8">
<div class="col-md-7">
<div class="panel panel-default">
<div class="panel-heading">
<a class="pull-right btn btn-default btn-xs" href="{% url "dashboard.views.template-list" %}">{% trans "Back" %}</a>
......@@ -23,33 +23,51 @@
</div>
</div>
<div class="col-md-4">
<div class="col-md-5">
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="no-margin"><i class="icon-group"></i> {% trans "Manage access" %}</h4>
</div>
<div class="panel-body">
<form action="{% url "dashboard.views.template-acl" pk=object.pk %}" method="post">{% csrf_token %}
<table class="table table-striped table-with-form-fields">
<thead><tr><th></th><th>{% trans "Who" %}</th><th>{% trans "What" %}</th><th></th></tr></thead>
<table class="table table-striped table-with-form-fields" id="template-access-table">
<thead>
<tr>
<th></th>
<th>{% trans "Who" %}</th>
<th>{% trans "What" %}</th>
<th><i class="icon-remove"></i></th>
</tr></thead>
<tbody>
{% for i in acl.users %}
<tr><td><i class="icon-user"></i></td><td>{{i.user}}</td>
<td><select class="form-control" name="perm-u-{{i.user.id}}">
{% for id, name in acl.levels %}
<option{%if id = i.level%} selected="selected"{%endif%} value="{{id}}">{{name}}</option>
{% endfor %}
</select></td>
<td><a href="#" class="btn btn-link btn-xs"><i class="icon-remove"><span class="sr-only">{% trans "remove" %}</span></i></a></td></tr>
<tr>
<td><i class="icon-user"></i></td><td>{{i.user}}</td>
<td>
<select class="form-control" name="perm-u-{{i.user.id}}">
{% for id, name in acl.levels %}
<option{%if id = i.level%} selected="selected"{%endif%} value="{{id}}">{{name}}</option>
{% endfor %}
</select>
</td>
<td>
<input type="checkbox" name="remove-u-{{i.user.id}}" title="{% trans "Remove" %}"/>
</td>
</tr>
{% endfor %}
{% for i in acl.groups %}
<tr><td><i class="icon-group"></i></td><td>{{i.group}}</td>
<td><select class="form-control" name="perm-g-{{i.group.id}}">
{% for id, name in acl.levels %}
<option{%if id = i.level%} selected="selected"{%endif%} value="{{id}}">{{name}}</option>
{% endfor %}
</select></td>
<td><a href="#" class="btn btn-link btn-xs"><i class="icon-remove"><span class="sr-only">{% trans "remove" %}</span></i></a></td></tr>
<tr>
<td><i class="icon-group"></i></td><td>{{i.group}}</td>
<td>
<select class="form-control" name="perm-g-{{i.group.id}}">
{% for id, name in acl.levels %}
<option{%if id = i.level%} selected="selected"{%endif%} value="{{id}}">{{name}}</option>
{% endfor %}
</select>
</td>
<td>
<input type="checkbox" name="remove-g-{{i.group.id}}" title="{% trans "Remove" %}"/>
</td>
</tr>
{% endfor %}
<tr><td><i class="icon-plus"></i></td>
<td><input type="text" class="form-control" name="perm-new-name"
......
......@@ -9,6 +9,14 @@
{{ a.get_readable_name }}{% if user.is_superuser %}</a>{% endif %}
</strong>
{{ a.started|date:"Y-m-d H:i" }}{% if a.user %}, {{ a.user }}{% endif %}
{% if a.is_abortable_for_user %}
<form action="{{ a.instance.get_absolute_url }}" method="POST" class="pull-right">
{% csrf_token %}
<input type="hidden" name="abort_operation"/>
<input type="hidden" name="activity" value="{{ a.pk }}"/>
<button class="btn btn-danger btn-xs"><i class="icon-bolt"></i> {% trans "Abort" %}</button>
</form>
{% endif %}
{% if a.children.count > 0 %}
<div class="sub-timeline">
{% for s in a.children.all %}
......
......@@ -15,26 +15,43 @@
</p>
<h3>{% trans "Permissions"|capfirst %}</h3>
<form action="{{acl.url}}" method="post">{% csrf_token %}
<table class="table table-striped table-with-form-fields">
<thead><tr><th></th><th>{% trans "Who" %}</th><th>{% trans "What" %}</th><th></th></tr></thead>
<table class="table table-striped table-with-form-fields" id="vm-access-table">
<thead><tr>
<th></th>
<th>{% trans "Who" %}</th>
<th>{% trans "What" %}</th>
<th>{% trans "Remove" %}</th>
</tr></thead>
<tbody>
{% for i in acl.users %}
<tr><td><i class="icon-user"></i></td><td>{{i.user}}</td>
<td><select class="form-control" name="perm-u-{{i.user.id}}">
{% for id, name in acl.levels %}
<option{%if id = i.level%} selected="selected"{%endif%} value="{{id}}">{{name}}</option>
{% endfor %}
</select></td>
<td><a href="#" class="btn btn-link btn-xs"><i class="icon-remove"><span class="sr-only">{% trans "remove" %}</span></i></a></td></tr>
<tr>
<td><i class="icon-user"></i></td>
<td>{{i.user}}</td>
<td>
<select class="form-control" name="perm-u-{{i.user.id}}">
{% for id, name in acl.levels %}
<option{%if id = i.level%} selected="selected"{%endif%} value="{{id}}">{{name}}</option>
{% endfor %}
</select>
</td>
<td>
<input type="checkbox" name="remove-u-{{i.user.id}}"/>
</td>
</tr>
{% endfor %}
{% for i in acl.groups %}
<tr><td><i class="icon-group"></i></td><td>{{i.group}}</td>
<td><select class="form-control" name="perm-g-{{i.group.id}}">
{% for id, name in acl.levels %}
<option{%if id = i.level%} selected="selected"{%endif%} value="{{id}}">{{name}}</option>
{% endfor %}
<tr>
<td><i class="icon-group"></i></td><td>{{i.group}}</td>
<td>
<select class="form-control" name="perm-g-{{i.group.id}}">
{% for id, name in acl.levels %}
<option{%if id = i.level%} selected="selected"{%endif%} value="{{id}}">{{name}}</option>
{% endfor %}
</select></td>
<td><a href="#" class="btn btn-link btn-xs"><i class="icon-remove"><span class="sr-only">{% trans "remove" %}</span></i></a></td></tr>
<td>
<input type="checkbox" name="remove-g-{{i.group.id}}"/>
</td>
</tr>
{% endfor %}
<tr><td><i class="icon-plus"></i></td>
<td><input type="text" class="form-control" name="perm-new-name"
......
......@@ -1161,3 +1161,121 @@ class ProfileViewTest(LoginMixin, TestCase):
self.assertIsNotNone(authenticate(username="user1",
password="password"))
self.assertIsNone(authenticate(username="user1", password="asd"))
class AclViewTest(LoginMixin, TestCase):
fixtures = ['test-vm-fixture.json', 'node.json']
def setUp(self):
Instance.get_remote_queue_name = Mock(return_value='test')
self.u1 = User.objects.create(username='user1')
self.u1.set_password('password')
self.u1.save()
self.u2 = User.objects.create(username='user2', is_staff=True)
self.u2.set_password('password')
self.u2.save()
self.us = User.objects.create(username='superuser', is_superuser=True)
self.us.set_password('password')
self.us.save()
self.ut = User.objects.get(username="test")
self.g1 = Group.objects.create(name='group1')
self.g1.user_set.add(self.u1)
self.g1.user_set.add(self.u2)
self.g1.save()
settings["default_vlangroup"] = 'public'
VlanGroup.objects.create(name='public')
def tearDown(self):
super(AclViewTest, self).tearDown()
self.u1.delete()
self.u2.delete()
self.us.delete()
self.g1.delete()
def test_permitted_instance_access_revoke(self):
c = Client()
# this is from the fixtures
self.login(c, "test", "test")
inst = Instance.objects.get(id=1)
inst.set_level(self.u1, "user")
resp = c.post("/dashboard/vm/1/acl/", {
'remove-u-%d' % self.u1.pk: "",
'perm-new-name': "",
'perm-new': "",
})
self.assertFalse((self.u1, "user") in inst.get_users_with_level())
self.assertEqual(resp.status_code, 302)
def test_unpermitted_instance_access_revoke(self):
c = Client()
self.login(c, self.u2)
inst = Instance.objects.get(id=1)
inst.set_level(self.u1, "user")
resp = c.post("/dashboard/vm/1/acl/", {
'remove-u-%d' % self.u1.pk: "",
'perm-new-name': "",
'perm-new': "",
})
self.assertTrue((self.u1, "user") in inst.get_users_with_level())
self.assertEqual(resp.status_code, 403)
def test_instance_original_owner_access_revoke(self):
c = Client()
self.login(c, self.u1)
inst = Instance.objects.get(id=1)
inst.set_level(self.u1, "owner")
inst.set_level(self.ut, "owner")
resp = c.post("/dashboard/vm/1/acl/", {
'remove-u-%d' % self.ut.pk: "",
'perm-new-name': "",
'perm-new': "",
})
self.assertEqual(self.ut, Instance.objects.get(id=1).owner)
self.assertTrue((self.ut, "owner") in inst.get_users_with_level())
self.assertEqual(resp.status_code, 302)
def test_permitted_template_access_revoke(self):
c = Client()
# this is from the fixtures
self.login(c, "test", "test")
tmpl = InstanceTemplate.objects.get(id=1)
tmpl.set_level(self.u1, "user")
resp = c.post("/dashboard/template/1/acl/", {
'remove-u-%d' % self.u1.pk: "",
'perm-new-name': "",
'perm-new': "",
})
self.assertFalse((self.u1, "user") in tmpl.get_users_with_level())
self.assertEqual(resp.status_code, 302)
def test_unpermitted_template_access_revoke(self):
c = Client()
self.login(c, self.u2)
tmpl = InstanceTemplate.objects.get(id=1)
tmpl.set_level(self.u1, "user")
resp = c.post("/dashboard/template/1/acl/", {
'remove-u-%d' % self.u1.pk: "",
'perm-new-name': "",
'perm-new': "",
})
self.assertTrue((self.u1, "user") in tmpl.get_users_with_level())
self.assertEqual(resp.status_code, 403)
def test_template_original_owner_access_revoke(self):
c = Client()
self.login(c, self.u1)
tmpl = InstanceTemplate.objects.get(id=1)
tmpl.set_level(self.u1, "owner")
tmpl.set_level(self.ut, "owner")
resp = c.post("/dashboard/template/1/acl/", {
'remove-u-%d' % self.ut.pk: "",
'perm-new-name': "",
'perm-new': "",
})
self.assertEqual(self.ut, InstanceTemplate.objects.get(id=1).owner)
self.assertTrue((self.ut, "owner") in tmpl.get_users_with_level())
self.assertEqual(resp.status_code, 302)
......@@ -224,11 +224,7 @@ class VmDetailView(CheckedDetailView):
})
# activity data
context['activities'] = (
InstanceActivity.objects.filter(
instance=self.object, parent=None).
order_by('-started').
select_related('user').prefetch_related('children'))
context['activities'] = self.object.get_activities(self.request.user)
context['vlans'] = Vlan.get_objects_with_level(
'user', self.request.user
......@@ -260,6 +256,7 @@ class VmDetailView(CheckedDetailView):
'to_remove': self.__remove_tag,
'port': self.__add_port,
'new_network_vlan': self.__new_network,
'abort_operation': self.__abort_operation,
}
for k, v in options.iteritems():
if request.POST.get(k) is not None:
......@@ -445,6 +442,16 @@ class VmDetailView(CheckedDetailView):
return redirect("%s#network" % reverse_lazy(
"dashboard.views.detail", kwargs={'pk': self.object.pk}))
def __abort_operation(self, request):
self.object = self.get_object()
activity = get_object_or_404(InstanceActivity,
pk=request.POST.get("activity"))
if not activity.is_abortable_for(request.user):
raise PermissionDenied()
activity.abort()
return redirect("%s#activity" % self.object.get_absolute_url())
class OperationView(DetailView):
......@@ -714,8 +721,9 @@ class AclUpdateView(LoginRequiredMixin, View, SingleObjectMixin):
unicode(instance), unicode(request.user))
raise PermissionDenied()
self.set_levels(request, instance)
self.remove_levels(request, instance)
self.add_levels(request, instance)
return redirect(instance)
return redirect("%s#access" % instance.get_absolute_url())
def set_levels(self, request, instance):
for key, value in request.POST.items():
......@@ -732,6 +740,24 @@ class AclUpdateView(LoginRequiredMixin, View, SingleObjectMixin):
unicode(entity), unicode(instance),
value, unicode(request.user))
def remove_levels(self, request, instance):
for key, value in request.POST.items():
if key.startswith("remove"):
typ = key[7:8] # len("remove-")
id = key[9:] # len("remove-x-")
entity = {'u': User, 'g': Group}[typ].objects.get(id=id)
if getattr(instance, "owner", None) == entity:
logger.info("Tried to remove owner from %s by %s.",
unicode(instance), unicode(request.user))
msg = _("The original owner cannot be removed, however "
"you can transfer ownership!")
messages.warning(request, msg)
continue
instance.set_level(entity, None)
logger.info("Revoked %s's access to %s by %s.",
unicode(entity), unicode(instance),
unicode(request.user))
def add_levels(self, request, instance):
name = request.POST['perm-new-name']
value = request.POST['perm-new']
......@@ -772,6 +798,7 @@ class TemplateAclUpdateView(AclUpdateView):
else:
self.set_levels(request, template)
self.add_levels(request, template)
self.remove_levels(request, template)
post_for_disk = request.POST.copy()
post_for_disk['perm-new'] = 'user'
......@@ -779,8 +806,7 @@ class TemplateAclUpdateView(AclUpdateView):
for d in template.disks.all():
self.add_levels(request, d)
return redirect(reverse("dashboard.views.template-detail",
kwargs=self.kwargs))
return redirect(template)
class GroupAclUpdateView(AclUpdateView):
......@@ -1791,9 +1817,7 @@ def vm_activity(request, pk):
if only_status == "false": # instance activity
context = {
'instance': instance,
'activities': InstanceActivity.objects.filter(
instance=instance, parent=None
).order_by('-started').select_related(),
'activities': instance.get_activities(request.user),
'ops': get_operations(instance, request.user),
}
......@@ -2398,10 +2422,8 @@ class InstanceActivityDetail(SuperuserRequiredMixin, DetailView):
def get_context_data(self, **kwargs):
ctx = super(InstanceActivityDetail, self).get_context_data(**kwargs)
ctx['activities'] = (
self.object.instance.activity_log.filter(parent=None).
order_by('-started').select_related('user').
prefetch_related('children'))
ctx['activities'] = self.object.instance.get_activities(