Commit d0dc1ed8 by Őry Máté

Merge branch 'master' into issue-139

Conflicts:
	circle/vm/models/activity.py
parents 06663c9e 2ca7eb56
...@@ -59,10 +59,10 @@ def create_levels(app, created_models, verbosity, db=DEFAULT_DB_ALIAS, ...@@ -59,10 +59,10 @@ def create_levels(app, created_models, verbosity, db=DEFAULT_DB_ALIAS,
] ]
Level.objects.using(db).bulk_create(levels) Level.objects.using(db).bulk_create(levels)
if verbosity >= 2: if verbosity >= 2:
print("Adding levels [%s]." % ", ".join(levels)) print("Adding levels [%s]." % ", ".join(unicode(l) for l in levels))
print("Searched: [%s]." % ", ".join( print("Searched: [%s]." % ", ".join(
[unicode(l) for l in searched_levels])) unicode(l) for l in searched_levels))
print("All: [%s]." % ", ".join([unicode(l) for l in all_levels])) print("All: [%s]." % ", ".join(unicode(l) for l in all_levels))
# set weights # set weights
for ctype, codename, weight in level_weights: for ctype, codename, weight in level_weights:
......
...@@ -46,8 +46,9 @@ CACHES = { ...@@ -46,8 +46,9 @@ CACHES = {
LOGGING['loggers']['djangosaml2'] = {'handlers': ['console'], LOGGING['loggers']['djangosaml2'] = {'handlers': ['console'],
'level': 'CRITICAL'} 'level': 'CRITICAL'}
LOGGING['handlers']['console'] = {'level': 'WARNING', level = environ.get('LOGLEVEL', 'CRITICAL')
LOGGING['handlers']['console'] = {'level': level,
'class': 'logging.StreamHandler', 'class': 'logging.StreamHandler',
'formatter': 'simple'} 'formatter': 'simple'}
for i in LOCAL_APPS: for i in LOCAL_APPS:
LOGGING['loggers'][i] = {'handlers': ['console'], 'level': 'CRITICAL'} LOGGING['loggers'][i] = {'handlers': ['console'], 'level': level}
...@@ -59,6 +59,8 @@ class Operation(object): ...@@ -59,6 +59,8 @@ class Operation(object):
skip_auth_check = auxargs.pop('system') skip_auth_check = auxargs.pop('system')
user = auxargs.pop('user') user = auxargs.pop('user')
parent_activity = auxargs.pop('parent_activity') parent_activity = auxargs.pop('parent_activity')
if parent_activity and user is None and not skip_auth_check:
user = parent_activity.user
# check for unexpected keyword arguments # check for unexpected keyword arguments
argspec = getargspec(self._operation) argspec = getargspec(self._operation)
......
...@@ -612,6 +612,9 @@ class TemplateForm(forms.ModelForm): ...@@ -612,6 +612,9 @@ class TemplateForm(forms.ModelForm):
self.instance.ram_size = 512 self.instance.ram_size = 512
self.instance.num_cores = 2 self.instance.num_cores = 2
self.fields["lease"].queryset = Lease.get_objects_with_level(
"operator", self.user)
def clean_owner(self): def clean_owner(self):
if self.instance.pk is not None: if self.instance.pk is not None:
return User.objects.get(pk=self.instance.owner.pk) return User.objects.get(pk=self.instance.owner.pk)
...@@ -888,6 +891,27 @@ class LeaseForm(forms.ModelForm): ...@@ -888,6 +891,27 @@ class LeaseForm(forms.ModelForm):
model = Lease model = Lease
class VmRenewForm(forms.Form):
def __init__(self, *args, **kwargs):
choices = kwargs.pop('choices')
default = kwargs.pop('default')
super(VmRenewForm, self).__init__(*args, **kwargs)
self.fields['lease'] = forms.ModelChoiceField(queryset=choices,
initial=default,
required=True,
label=_('Length'))
if len(choices) < 2:
self.fields['lease'].widget = HiddenInput()
@property
def helper(self):
helper = FormHelper(self)
helper.form_tag = False
return helper
class VmCreateDiskForm(forms.Form): class VmCreateDiskForm(forms.Form):
name = forms.CharField(max_length=100, label=_("Name")) name = forms.CharField(max_length=100, label=_("Name"))
size = forms.CharField( size = forms.CharField(
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-7">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<a class="pull-right btn btn-default btn-xs" href="{% url "dashboard.views.template-list" %}">{% trans "Back" %}</a> <a class="pull-right btn btn-default btn-xs" href="{% url "dashboard.views.template-list" %}">{% trans "Back" %}</a>
...@@ -20,6 +20,85 @@ ...@@ -20,6 +20,85 @@
</div> </div>
</div> </div>
</div> </div>
<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.lease-acl" pk=object.pk %}" method="post">{% csrf_token %}
<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>
<a href="{% url "dashboard.views.profile" username=i.user.username %}"
title="{{ i.user.username }}">
{% include "dashboard/_display-name.html" with user=i.user show_org=True %}
</a>
</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>
<a href="{% url "dashboard.views.group-detail" pk=i.group.pk %}">
{{i.group}}
</a>
</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"
placeholder="{% trans "Name of group or user" %}"></td>
<td><select class="form-control" name="perm-new">
{% for id, name in acl.levels %}
<option value="{{id}}">{{name}}</option>
{% endfor %}
</select></td><td></td>
</tr>
</tbody>
</table>
<div class="form-actions">
<button type="submit" class="btn btn-success">{% trans "Save" %}</button>
</div>
</form>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
...@@ -26,9 +26,11 @@ ...@@ -26,9 +26,11 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
{% if perms.vm.create_leases %}
<a href="{% url "dashboard.views.lease-create" %}" class="pull-right btn btn-success btn-xs" style="margin-right: 10px;"> <a href="{% url "dashboard.views.lease-create" %}" class="pull-right btn btn-success btn-xs" style="margin-right: 10px;">
<i class="icon-plus"></i> {% trans "new lease" %} <i class="icon-plus"></i> {% trans "new lease" %}
</a> </a>
{% endif %}
<h3 class="no-margin"><i class="icon-time"></i> {% trans "Leases" %}</h3> <h3 class="no-margin"><i class="icon-time"></i> {% trans "Leases" %}</h3>
</div> </div>
<div class="panel-body"> <div class="panel-body">
......
...@@ -47,7 +47,12 @@ ...@@ -47,7 +47,12 @@
</dl> </dl>
<h4>{% trans "Expiration" %} {% if instance.is_expiring %}<i class="icon-warning-sign text-danger"></i>{% endif %} <h4>{% trans "Expiration" %} {% if instance.is_expiring %}<i class="icon-warning-sign text-danger"></i>{% endif %}
<a href="{% url "dashboard.views.vm-renew" instance.pk "" %}" class="btn btn-success btn-xs pull-right">{% trans "renew" %}</a> {% with op=op.renew %}
<a href="{{op.get_url}}" class="btn btn-success btn-xs
operation operation-{{op.op}} btn btn-default">
<i class="icon-{{op.icon}}"></i>
{{op.name}} </a>
{% endwith %}
</h4> </h4>
<dl> <dl>
<dt>{% trans "Suspended at:" %}</dt> <dt>{% trans "Suspended at:" %}</dt>
......
...@@ -16,13 +16,15 @@ ...@@ -16,13 +16,15 @@
# with CIRCLE. If not, see <http://www.gnu.org/licenses/>. # with CIRCLE. If not, see <http://www.gnu.org/licenses/>.
import unittest import unittest
import warnings
from factory import Factory, Sequence from factory import Factory, Sequence
from mock import patch, MagicMock from mock import patch, MagicMock
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.signing import TimestampSigner, JSONSerializer, b64_encode from django.core.signing import TimestampSigner, JSONSerializer, b64_encode
from django.http import HttpRequest, Http404 from django.http import HttpRequest, Http404, QueryDict
from django.utils import baseconv from django.utils import baseconv
from ..models import Profile from ..models import Profile
...@@ -142,7 +144,7 @@ class VmOperationViewTestCase(unittest.TestCase): ...@@ -142,7 +144,7 @@ class VmOperationViewTestCase(unittest.TestCase):
view.as_view()(request, pk=1234).render() view.as_view()(request, pk=1234).render()
def test_migrate(self): def test_migrate(self):
request = FakeRequestFactory(POST={'node': 1}) request = FakeRequestFactory(POST={'node': 1}, superuser=True)
view = vm_ops['migrate'] view = vm_ops['migrate']
with patch.object(view, 'get_object') as go, \ with patch.object(view, 'get_object') as go, \
...@@ -176,7 +178,24 @@ class VmOperationViewTestCase(unittest.TestCase): ...@@ -176,7 +178,24 @@ class VmOperationViewTestCase(unittest.TestCase):
assert view.as_view()(request, pk=1234)['location'] assert view.as_view()(request, pk=1234)['location']
assert msg.error.called assert msg.error.called
def test_migrate_wo_permission(self):
request = FakeRequestFactory(POST={'node': 1}, superuser=False)
view = vm_ops['migrate']
with patch.object(view, 'get_object') as go, \
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()
with self.assertRaises(PermissionDenied):
assert view.as_view()(request, pk=1234)['location']
def test_migrate_template(self): def test_migrate_template(self):
"""check if GET dialog's template can be rendered"""
request = FakeRequestFactory(superuser=True) request = FakeRequestFactory(superuser=True)
view = vm_ops['migrate'] view = vm_ops['migrate']
...@@ -207,7 +226,8 @@ class VmOperationViewTestCase(unittest.TestCase): ...@@ -207,7 +226,8 @@ class VmOperationViewTestCase(unittest.TestCase):
assert not msg.error.called assert not msg.error.called
def test_save_as_w_name(self): def test_save_as_w_name(self):
request = FakeRequestFactory(POST={'name': 'foobar'}) request = FakeRequestFactory(POST={'name': 'foobar'},
has_perms_mock=True)
view = vm_ops['save_as_template'] view = vm_ops['save_as_template']
with patch.object(view, 'get_object') as go, \ with patch.object(view, 'get_object') as go, \
...@@ -238,26 +258,204 @@ class VmOperationViewTestCase(unittest.TestCase): ...@@ -238,26 +258,204 @@ class VmOperationViewTestCase(unittest.TestCase):
self.assertEquals(rend.status_code, 200) self.assertEquals(rend.status_code, 200)
def FakeRequestFactory(*args, **kwargs): class RenewViewTest(unittest.TestCase):
def test_renew_template(self):
request = FakeRequestFactory(has_perms_mock=True)
view = vm_ops['renew']
with patch.object(view, 'get_object') as go:
inst = MagicMock(spec=Instance)
inst._meta.object_name = "Instance"
inst.name = 'foo'
inst.renew = Instance._ops['renew'](inst)
inst.has_level.return_value = True
go.return_value = inst
rend = view.as_view()(request, pk=1234).render()
self.assertEquals(rend.status_code, 200)
def test_renew_by_owner_wo_param(self):
request = FakeRequestFactory(POST={}, has_perms_mock=True)
view = vm_ops['renew']
with patch.object(view, 'get_object') as go, \
patch('dashboard.views.get_object_or_404') as go4:
inst = MagicMock(spec=Instance)
inst._meta.object_name = "Instance"
inst.renew = Instance._ops['renew'](inst)
inst.renew.async = MagicMock()
inst.has_level.return_value = True
go.return_value = inst
go4.return_value = MagicMock()
assert view.as_view()(request, pk=1234).render().status_code == 200
# success would redirect
def test_renew_by_owner_w_param(self):
request = FakeRequestFactory(POST={'length': 1}, has_perms_mock=True)
view = vm_ops['renew']
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.renew = Instance._ops['renew'](inst)
inst.renew.async = MagicMock()
inst.has_level.return_value = True
go.return_value = inst
go4.return_value = MagicMock()
assert view.as_view()(request, pk=1234)
assert not msg.error.called
def test_renew_get_by_anon_wo_key(self):
request = FakeRequestFactory(authenticated=False)
view = vm_ops['renew']
with patch.object(view, 'get_object') as go, \
patch('dashboard.views.get_object_or_404') as go4:
inst = MagicMock(spec=Instance)
inst._meta.object_name = "Instance"
inst.renew = Instance._ops['renew'](inst)
inst.renew.async = MagicMock()
inst.has_level.return_value = False
go.return_value = inst
go4.return_value = MagicMock()
self.assertIn('login',
view.as_view()(request, pk=1234)['location'])
def test_renew_get_by_nonowner_wo_key(self):
request = FakeRequestFactory(has_perms_mock=True)
view = vm_ops['renew']
with patch.object(view, 'get_object') as go, \
patch('dashboard.views.get_object_or_404') as go4:
inst = MagicMock(spec=Instance)
inst._meta.object_name = "Instance"
inst.renew = Instance._ops['renew'](inst)
inst.renew.async = MagicMock()
inst.has_level.return_value = False
go.return_value = inst
go4.return_value = MagicMock()
with self.assertRaises(PermissionDenied):
assert view.as_view()(request, pk=1234)
def test_renew_post_by_nonowner_wo_key(self):
request = FakeRequestFactory(POST={'length': 1}, has_perms_mock=True)
view = vm_ops['renew']
with patch.object(view, 'get_object') as go, \
patch('dashboard.views.get_object_or_404') as go4:
inst = MagicMock(spec=Instance, pk=11)
inst._meta.object_name = "Instance"
inst.renew = Instance._ops['renew'](inst)
inst.renew.async = MagicMock()
inst.has_level.return_value = False
go.return_value = inst
go4.return_value = MagicMock()
with self.assertRaises(PermissionDenied):
assert view.as_view()(request, pk=1234)
def test_renew_get_by_nonowner_w_key(self):
user = FakeRequestFactory(superuser=True).user
view = vm_ops['renew']
inst = MagicMock(spec=Instance, pk=11)
inst._meta.object_name = "Instance"
inst.renew = Instance._ops['renew'](inst)
inst.renew.async = MagicMock()
key = view.get_token_url(inst, user).split('?')[1].split('=')[1]
request = FakeRequestFactory(GET={'k': key}) # other user!
with patch.object(view, 'get_object') as go, \
patch('dashboard.views.User.objects') as gu, \
patch('dashboard.views.Lease.get_objects_with_level') as gol:
gol.return_value = views.Lease.objects.all()
gu.get.return_value = user
go.return_value = inst
assert view.as_view()(request, pk=1234).render().status_code == 200
def test_renew_post_by_anon_w_key(self):
user = FakeRequestFactory(authenticated=True).user
view = vm_ops['renew']
inst = MagicMock(spec=Instance, pk=11)
inst._meta.object_name = "Instance"
inst.renew = Instance._ops['renew'](inst)
inst.renew.async = MagicMock()
inst.has_level = lambda user, level: user.is_authenticated()
key = view.get_token_url(inst, user).split('?')[1].split('=')[1]
request = FakeRequestFactory(GET={'k': key}, authenticated=False)
with patch.object(view, 'get_object') as go, \
patch('dashboard.views.Lease.get_objects_with_level') as gol:
go.return_value = inst
gol.return_value = views.Lease.objects.all()
assert view.as_view()(request, pk=1234).render().status_code == 200
def test_renew_post_by_anon_w_invalid_key(self):
view = vm_ops['renew']
key = "invalid"
inst = MagicMock(spec=Instance, pk=11)
inst._meta.object_name = "Instance"
inst.renew = Instance._ops['renew'](inst)
inst.renew.async = MagicMock()
inst.has_level.return_value = False
request = FakeRequestFactory(GET={'k': key}, authenticated=False)
with patch.object(view, 'get_object') as go:
go.return_value = inst
self.assertIn('login',
view.as_view()(request, pk=1234)['location'])
def test_renew_post_by_anon_w_expired_key(self):
def side(max_age=None, *args, **kwargs):
if max_age:
raise views.signing.BadSignature
user = FakeRequestFactory(authenticated=False).user
view = vm_ops['renew']
inst = MagicMock(spec=Instance, pk=11)
inst._meta.object_name = "Instance"
inst.renew = Instance._ops['renew'](inst)
inst.renew.async = MagicMock()
inst.has_level.return_value = False
key = view.get_token_url(inst, user).split('?')[1].split('=')[1]
with patch('dashboard.views.signing.loads') as loader, \
patch.object(view, 'get_object') as go:
loader.return_value = (inst.pk, user.pk)
loader.side_effect = side
request = FakeRequestFactory(GET={'k': key}, user=user)
go.return_value = inst
self.assertIn('login',
view.as_view()(request, pk=1234)['location'])
def FakeRequestFactory(user=None, **kwargs):
''' FakeRequestFactory, FakeMessages and FakeRequestContext are good for ''' FakeRequestFactory, FakeMessages and FakeRequestContext are good for
mocking out django views; they are MUCH faster than the Django test client. mocking out django views; they are MUCH faster than the Django test client.
''' '''
if user is None:
user = UserFactory() user = UserFactory()
user.is_authenticated = lambda: kwargs.get('authenticated', True) user.is_authenticated = lambda: kwargs.pop('authenticated', True)
user.is_superuser = kwargs.get('superuser', False) user.is_superuser = kwargs.pop('superuser', False)
if kwargs.get('has_perms_mock', False): if kwargs.pop('has_perms_mock', False):
user.has_perms = MagicMock(return_value=True) user.has_perms = MagicMock(return_value=True)
user.save()
request = HttpRequest() request = HttpRequest()
request.user = user request.user = user
request.session = kwargs.get('session', {}) request.session = kwargs.pop('session', {})
if kwargs.get('POST') is not None: if kwargs.get('POST') is not None:
request.method = 'POST' request.method = 'POST'
request.POST = kwargs.get('POST') request.POST = QueryDict('', mutable=True)
request.POST.update(kwargs.pop('POST'))
else: else:
request.method = 'GET' request.method = 'GET'
request.GET = kwargs.get('GET', {}) request.GET = QueryDict('', mutable=True)
request.GET.update(kwargs.pop('GET', {}))
if len(kwargs):
warnings.warn("FakeRequestFactory kwargs unused: " + unicode(kwargs))
return request return request
......
...@@ -21,14 +21,12 @@ import json ...@@ -21,14 +21,12 @@ import json
from django.test import TestCase from django.test import TestCase
from django.test.client import Client from django.test.client import Client
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.core.urlresolvers import reverse
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from vm.models import Instance, InstanceTemplate, Lease, Node, Trait from vm.models import Instance, InstanceTemplate, Lease, Node, Trait
from vm.operations import WakeUpOperation from vm.operations import WakeUpOperation
from ..models import Profile from ..models import Profile
from ..views import VmRenewView
from storage.models import Disk from storage.models import Disk
from firewall.models import Vlan, Host, VlanGroup from firewall.models import Vlan, Host, VlanGroup
from mock import Mock, patch from mock import Mock, patch
...@@ -568,10 +566,8 @@ class VmDetailTest(LoginMixin, TestCase): ...@@ -568,10 +566,8 @@ class VmDetailTest(LoginMixin, TestCase):
inst = Instance.objects.get(pk=1) inst = Instance.objects.get(pk=1)
inst.manual_state_change('SUSPENDED') inst.manual_state_change('SUSPENDED')
inst.set_level(self.u2, 'user') inst.set_level(self.u2, 'user')
with patch('dashboard.views.messages') as msg:
response = c.post("/dashboard/vm/1/op/wake_up/") response = c.post("/dashboard/vm/1/op/wake_up/")
assert msg.error.called self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 302)
inst = Instance.objects.get(pk=1) inst = Instance.objects.get(pk=1)
self.assertEqual(inst.status, 'SUSPENDED') self.assertEqual(inst.status, 'SUSPENDED')
...@@ -1631,100 +1627,6 @@ class TransferOwnershipViewTest(LoginMixin, TestCase): ...@@ -1631,100 +1627,6 @@ class TransferOwnershipViewTest(LoginMixin, TestCase):
self.assertEquals(Instance.objects.get(pk=1).owner.pk, self.u2.pk) self.assertEquals(Instance.objects.get(pk=1).owner.pk, self.u2.pk)
class RenewViewTest(LoginMixin, TestCase):
fixtures = ['test-vm-fixture.json']
def setUp(self):
self.u1 = User.objects.create(username='user1')
self.u1.set_password('password')
self.u1.save()
Profile.objects.create(user=self.u1)
self.u2 = User.objects.create(username='user2', is_staff=True)
self.u2.set_password('password')
self.u2.save()
Profile.objects.create(user=self.u2)
self.us = User.objects.create(username='superuser', is_superuser=True)
self.us.set_password('password')
self.us.save()
Profile.objects.create(user=self.us)
inst = Instance.objects.get(pk=1)
inst.owner = self.u1
inst.save()
def test_renew_by_owner(self):
c = Client()
ct = Instance.objects.get(pk=1).activity_log.\
filter(activity_code__endswith='renew').count()
self.login(c, 'user1')
response = c.get('/dashboard/vm/1/renew/')
self.assertEquals(response.status_code, 200)
response = c.post('/dashboard/vm/1/renew/')
self.assertEquals(response.status_code, 302)
ct2 = Instance.objects.get(pk=1).activity_log.\
filter(activity_code__endswith='renew').count()
self.assertEquals(ct + 1, ct2)
def test_renew_get_by_nonowner_wo_key(self):
c = Client()
self.login(c, 'user2')
response = c.get('/dashboard/vm/1/renew/')
self.assertEquals(response.status_code, 403)
def test_renew_post_by_nonowner_wo_key(self):
c = Client()
self.login(c, 'user2')
response = c.post('/dashboard/vm/1/renew/')
self.assertEquals(response.status_code, 403)
def test_renew_get_by_nonowner_w_key(self):
key = VmRenewView.get_token_url(Instance.objects.get(pk=1), self.u2)
c = Client()
response = c.get(key)
self.assertEquals(response.status_code, 200)
def test_renew_post_by_anon_w_key(self):
key = VmRenewView.get_token_url(Instance.objects.get(pk=1), self.u2)
ct = Instance.objects.get(pk=1).activity_log.\
filter(activity_code__endswith='renew').count()
c = Client()
response = c.post(key)
self.assertEquals(response.status_code, 302)
ct2 = Instance.objects.get(pk=1).activity_log.\
filter(activity_code__endswith='renew').count()
self.assertEquals(ct + 1, ct2)
def test_renew_post_by_anon_w_invalid_key(self):
class Mockinst(object):
pk = 2
key = VmRenewView.get_token_url(Mockinst(), self.u2)
ct = Instance.objects.get(pk=1).activity_log.\
filter(activity_code__endswith='renew').count()
c = Client()
self.login(c, 'user2')
response = c.get(key)
self.assertEquals(response.status_code, 404)
response = c.post(key)
self.assertEquals(response.status_code, 404)
ct2 = Instance.objects.get(pk=1).activity_log.\
filter(activity_code__endswith='renew').count()
self.assertEquals(ct, ct2)
def test_renew_post_by_anon_w_expired_key(self):
key = reverse(VmRenewView.url_name, args=(
12, 'WzEyLDFd:1WLbSi:2zIb8SUNAIRIOMTmSmKSSit2gpY'))
ct = Instance.objects.get(pk=12).activity_log.\
filter(activity_code__endswith='renew').count()
c = Client()
self.login(c, 'user2')
response = c.get(key)
self.assertEquals(response.status_code, 302)
response = c.post(key)
self.assertEquals(response.status_code, 403)
ct2 = Instance.objects.get(pk=12).activity_log.\
filter(activity_code__endswith='renew').count()
self.assertEquals(ct, ct2)
class IndexViewTest(LoginMixin, TestCase): class IndexViewTest(LoginMixin, TestCase):
fixtures = ['test-vm-fixture.json', 'node.json'] fixtures = ['test-vm-fixture.json', 'node.json']
......
...@@ -29,7 +29,7 @@ from .views import ( ...@@ -29,7 +29,7 @@ from .views import (
TemplateDelete, TemplateDetail, TemplateList, TransferOwnershipConfirmView, TemplateDelete, TemplateDetail, TemplateList, TransferOwnershipConfirmView,
TransferOwnershipView, vm_activity, VmCreate, VmDelete, VmDetailView, TransferOwnershipView, vm_activity, VmCreate, VmDelete, VmDetailView,
VmDetailVncTokenView, VmGraphView, VmList, VmMassDelete, VmDetailVncTokenView, VmGraphView, VmList, VmMassDelete,
VmRenewView, DiskRemoveView, get_disk_download_status, InterfaceDeleteView, DiskRemoveView, get_disk_download_status, InterfaceDeleteView,
GroupRemoveAclUserView, GroupRemoveAclGroupView, GroupRemoveUserView, GroupRemoveAclUserView, GroupRemoveAclGroupView, GroupRemoveUserView,
GroupRemoveFutureUserView, GroupRemoveFutureUserView,
GroupCreate, GroupProfileUpdate, GroupCreate, GroupProfileUpdate,
...@@ -40,6 +40,7 @@ from .views import ( ...@@ -40,6 +40,7 @@ from .views import (
UserKeyDelete, UserKeyDetail, UserKeyCreate, UserKeyDelete, UserKeyDetail, UserKeyCreate,
VmTraitsUpdate, VmRawDataUpdate, VmTraitsUpdate, VmRawDataUpdate,
GroupPermissionsView, GroupPermissionsView,
LeaseAclUpdateView,
) )
urlpatterns = patterns( urlpatterns = patterns(
...@@ -51,6 +52,8 @@ urlpatterns = patterns( ...@@ -51,6 +52,8 @@ urlpatterns = patterns(
name="dashboard.views.lease-create"), name="dashboard.views.lease-create"),
url(r'^lease/delete/(?P<pk>\d+)/$', LeaseDelete.as_view(), url(r'^lease/delete/(?P<pk>\d+)/$', LeaseDelete.as_view(),
name="dashboard.views.lease-delete"), name="dashboard.views.lease-delete"),
url(r'^lease/(?P<pk>\d+)/acl/$', LeaseAclUpdateView.as_view(),
name="dashboard.views.lease-acl"),
url(r'^template/create/$', TemplateCreate.as_view(), url(r'^template/create/$', TemplateCreate.as_view(),
name="dashboard.views.template-create"), name="dashboard.views.template-create"),
...@@ -84,8 +87,6 @@ urlpatterns = patterns( ...@@ -84,8 +87,6 @@ urlpatterns = patterns(
url(r'^vm/mass-delete/', VmMassDelete.as_view(), url(r'^vm/mass-delete/', VmMassDelete.as_view(),
name='dashboard.view.mass-delete-vm'), name='dashboard.view.mass-delete-vm'),
url(r'^vm/(?P<pk>\d+)/activity/$', vm_activity), url(r'^vm/(?P<pk>\d+)/activity/$', vm_activity),
url(r'^vm/(?P<pk>\d+)/renew/((?P<key>.*)/?)$', VmRenewView.as_view(),
name='dashboard.views.vm-renew'),
url(r'^vm/activity/(?P<pk>\d+)/$', InstanceActivityDetail.as_view(), url(r'^vm/activity/(?P<pk>\d+)/$', InstanceActivityDetail.as_view(),
name='dashboard.views.vm-activity'), name='dashboard.views.vm-activity'),
url(r'^vm/(?P<pk>\d+)/screenshot/$', get_vm_screenshot, url(r'^vm/(?P<pk>\d+)/screenshot/$', get_vm_screenshot,
......
...@@ -60,7 +60,7 @@ from .forms import ( ...@@ -60,7 +60,7 @@ from .forms import (
CircleAuthenticationForm, HostForm, LeaseForm, MyProfileForm, CircleAuthenticationForm, HostForm, LeaseForm, MyProfileForm,
NodeForm, TemplateForm, TraitForm, VmCustomizeForm, GroupCreateForm, NodeForm, TemplateForm, TraitForm, VmCustomizeForm, GroupCreateForm,
UserCreationForm, GroupProfileUpdateForm, UnsubscribeForm, UserCreationForm, GroupProfileUpdateForm, UnsubscribeForm,
VmSaveForm, UserKeyForm, VmSaveForm, UserKeyForm, VmRenewForm,
CirclePasswordChangeForm, VmCreateDiskForm, VmDownloadDiskForm, CirclePasswordChangeForm, VmCreateDiskForm, VmDownloadDiskForm,
TraitsForm, RawDataForm, GroupPermissionForm TraitsForm, RawDataForm, GroupPermissionForm
) )
...@@ -91,6 +91,23 @@ def search_user(keyword): ...@@ -91,6 +91,23 @@ def search_user(keyword):
return User.objects.get(email=keyword) return User.objects.get(email=keyword)
class RedirectToLoginMixin(AccessMixin):
redirect_exception_classes = (PermissionDenied, )
def dispatch(self, request, *args, **kwargs):
try:
return super(RedirectToLoginMixin, self).dispatch(
request, *args, **kwargs)
except self.redirect_exception_classes:
if not request.user.is_authenticated():
return redirect_to_login(request.get_full_path(),
self.get_login_url(),
self.get_redirect_field_name())
else:
raise
class GroupCodeMixin(object): class GroupCodeMixin(object):
@classmethod @classmethod
...@@ -498,7 +515,7 @@ class VmRawDataUpdate(SuperuserRequiredMixin, UpdateView): ...@@ -498,7 +515,7 @@ class VmRawDataUpdate(SuperuserRequiredMixin, UpdateView):
return self.get_object().get_absolute_url() + "#resources" return self.get_object().get_absolute_url() + "#resources"
class OperationView(DetailView): class OperationView(RedirectToLoginMixin, DetailView):
template_name = 'dashboard/operate.html' template_name = 'dashboard/operate.html'
show_in_toolbar = True show_in_toolbar = True
...@@ -519,8 +536,16 @@ class OperationView(DetailView): ...@@ -519,8 +536,16 @@ class OperationView(DetailView):
def get_urlname(cls): def get_urlname(cls):
return 'dashboard.vm.op.%s' % cls.op return 'dashboard.vm.op.%s' % cls.op
def get_url(self): @classmethod
return reverse(self.get_urlname(), args=(self.get_object().pk, )) def get_instance_url(cls, pk, key=None, *args, **kwargs):
url = reverse(cls.get_urlname(), args=(pk, ) + args, kwargs=kwargs)
if key is None:
return url
else:
return "%s?k=%s" % (url, key)
def get_url(self, **kwargs):
return self.get_instance_url(self.get_object().pk, **kwargs)
def get_template_names(self): def get_template_names(self):
if self.request.is_ajax(): if self.request.is_ajax():
...@@ -541,15 +566,23 @@ class OperationView(DetailView): ...@@ -541,15 +566,23 @@ class OperationView(DetailView):
ctx = super(OperationView, self).get_context_data(**kwargs) ctx = super(OperationView, self).get_context_data(**kwargs)
ctx['op'] = self.get_op() ctx['op'] = self.get_op()
ctx['opview'] = self ctx['opview'] = self
ctx['url'] = self.request.path url = self.request.path
if self.request.GET:
url += '?' + self.request.GET.urlencode()
ctx['url'] = url
ctx['template'] = super(OperationView, self).get_template_names()[0] ctx['template'] = super(OperationView, self).get_template_names()[0]
return ctx return ctx
def check_auth(self):
logger.debug("OperationView.check_auth(%s)", unicode(self))
self.get_op().check_auth(self.request.user)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
self.get_op().check_auth(request.user) self.check_auth()
return super(OperationView, self).get(request, *args, **kwargs) return super(OperationView, self).get(request, *args, **kwargs)
def post(self, request, extra=None, *args, **kwargs): def post(self, request, extra=None, *args, **kwargs):
self.check_auth()
self.object = self.get_object() self.object = self.get_object()
if extra is None: if extra is None:
extra = {} extra = {}
...@@ -557,31 +590,31 @@ class OperationView(DetailView): ...@@ -557,31 +590,31 @@ class OperationView(DetailView):
self.get_op().async(user=request.user, **extra) self.get_op().async(user=request.user, **extra)
except Exception as e: except Exception as e:
messages.error(request, _('Could not start operation.')) messages.error(request, _('Could not start operation.'))
logger.error(e) logger.exception(e)
else:
messages.success(request, _('Operation is started.'))
return redirect("%s#activity" % self.object.get_absolute_url()) return redirect("%s#activity" % self.object.get_absolute_url())
@classmethod @classmethod
def factory(cls, op, icon='cog', effect='info'): def factory(cls, op, icon='cog', effect='info', extra_bases=(), **kwargs):
kwargs.update({'op': op, 'icon': icon, 'effect': effect})
return type(str(cls.__name__ + op), return type(str(cls.__name__ + op),
(cls, ), {'op': op, 'icon': icon, 'effect': effect}) tuple(list(extra_bases) + [cls]), kwargs)
@classmethod @classmethod
def bind_to_object(cls, instance, **kwargs): def bind_to_object(cls, instance, **kwargs):
v = cls() me = cls()
v.get_object = lambda: instance me.get_object = lambda: instance
for key, value in kwargs.iteritems(): for key, value in kwargs.iteritems():
setattr(v, key, value) setattr(me, key, value)
return v return me
class VmOperationView(OperationView):
model = Instance class AjaxOperationMixin(object):
context_object_name = 'instance' # much simpler to mock object
def post(self, request, extra=None, *args, **kwargs): def post(self, request, extra=None, *args, **kwargs):
resp = super(VmOperationView, self).post(request, extra, *args, resp = super(AjaxOperationMixin, self).post(
**kwargs) request, extra, *args, **kwargs)
if request.is_ajax(): if request.is_ajax():
store = messages.get_messages(request) store = messages.get_messages(request)
store.used = True store.used = True
...@@ -594,22 +627,32 @@ class VmOperationView(OperationView): ...@@ -594,22 +627,32 @@ class VmOperationView(OperationView):
return resp return resp
class VmOperationView(AjaxOperationMixin, OperationView):
model = Instance
context_object_name = 'instance' # much simpler to mock object
class FormOperationMixin(object): class FormOperationMixin(object):
form_class = None form_class = None
def get_form_kwargs(self):
return {}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super(FormOperationMixin, self).get_context_data(**kwargs) ctx = super(FormOperationMixin, self).get_context_data(**kwargs)
if self.request.method == 'POST': if self.request.method == 'POST':
ctx['form'] = self.form_class(self.request.POST) ctx['form'] = self.form_class(self.request.POST,
**self.get_form_kwargs())
else: else:
ctx['form'] = self.form_class() ctx['form'] = self.form_class(**self.get_form_kwargs())
return ctx return ctx
def post(self, request, extra=None, *args, **kwargs): def post(self, request, extra=None, *args, **kwargs):
if extra is None: if extra is None:
extra = {} extra = {}
form = self.form_class(self.request.POST) form = self.form_class(self.request.POST, **self.get_form_kwargs())
if form.is_valid(): if form.is_valid():
extra.update(form.cleaned_data) extra.update(form.cleaned_data)
resp = super(FormOperationMixin, self).post( resp = super(FormOperationMixin, self).post(
...@@ -625,6 +668,14 @@ class FormOperationMixin(object): ...@@ -625,6 +668,14 @@ class FormOperationMixin(object):
return self.get(request) return self.get(request)
class RequestFormOperationMixin(FormOperationMixin):
def get_form_kwargs(self):
val = super(FormOperationMixin, self).get_form_kwargs()
val.update({'request': self.request})
return val
class VmCreateDiskView(FormOperationMixin, VmOperationView): class VmCreateDiskView(FormOperationMixin, VmOperationView):
op = 'create_disk' op = 'create_disk'
...@@ -696,12 +747,108 @@ class VmResourcesChangeView(VmOperationView): ...@@ -696,12 +747,108 @@ class VmResourcesChangeView(VmOperationView):
*args, **kwargs) *args, **kwargs)
class TokenOperationView(OperationView):
"""Abstract operation view with token support.
User can do the action with a valid token instead of logging in.
"""
token_max_age = 3 * 24 * 3600
redirect_exception_classes = (PermissionDenied, SuspiciousOperation, )
@classmethod
def get_salt(cls):
return unicode(cls)
@classmethod
def get_token(cls, instance, user):
t = tuple([getattr(i, 'pk', i) for i in [instance, user]])
return signing.dumps(t, salt=cls.get_salt(), compress=True)
@classmethod
def get_token_url(cls, instance, user):
key = cls.get_token(instance, user)
return cls.get_instance_url(instance.pk, key)
def check_auth(self):
if 'k' in self.request.GET:
try: # check if token is needed at all
return super(TokenOperationView, self).check_auth()
except Exception:
op = self.get_op()
pk = op.instance.pk
key = self.request.GET.get('k')
logger.debug("checking token supplied to %s",
self.request.get_full_path())
try:
user = self.validate_key(pk, key)
except signing.SignatureExpired:
messages.error(self.request, _('The token has expired.'))
else:
logger.info("Request user changed to %s at %s",
user, self.request.get_full_path())
self.request.user = user
else:
logger.debug("no token supplied to %s",
self.request.get_full_path())
return super(TokenOperationView, self).check_auth()
def validate_key(self, pk, key):
"""Get object based on signed token.
"""
try:
data = signing.loads(key, salt=self.get_salt())
logger.debug('Token data: %s', unicode(data))
instance, user = data
logger.debug('Extracted token data: instance: %s, user: %s',
unicode(instance), unicode(user))
except (signing.BadSignature, ValueError, TypeError) as e:
logger.warning('Tried invalid token. Token: %s, user: %s. %s',
key, unicode(self.request.user), unicode(e))
raise SuspiciousOperation()
try:
instance, user = signing.loads(key, max_age=self.token_max_age,
salt=self.get_salt())
logger.debug('Extracted non-expired token data: %s, %s',
unicode(instance), unicode(user))
except signing.BadSignature as e:
raise signing.SignatureExpired()
if pk != instance:
logger.debug('pk (%d) != instance (%d)', pk, instance)
raise SuspiciousOperation()
user = User.objects.get(pk=user)
return user
class VmRenewView(FormOperationMixin, TokenOperationView, VmOperationView):
op = 'renew'
icon = 'calendar'
effect = 'info'
show_in_toolbar = False
form_class = VmRenewForm
def get_form_kwargs(self):
choices = Lease.get_objects_with_level("user", self.request.user)
default = self.get_op().instance.lease
if default and default not in choices:
choices = list(choices) + [default]
val = super(VmRenewView, self).get_form_kwargs()
val.update({'choices': choices, 'default': default})
return val
vm_ops = OrderedDict([ vm_ops = OrderedDict([
('deploy', VmOperationView.factory( ('deploy', VmOperationView.factory(
op='deploy', icon='play', effect='success')), op='deploy', icon='play', effect='success')),
('wake_up', VmOperationView.factory( ('wake_up', VmOperationView.factory(
op='wake_up', icon='sun', effect='success')), op='wake_up', icon='sun', effect='success')),
('sleep', VmOperationView.factory( ('sleep', VmOperationView.factory(
extra_bases=[TokenOperationView],
op='sleep', icon='moon', effect='info')), op='sleep', icon='moon', effect='info')),
('migrate', VmMigrateView), ('migrate', VmMigrateView),
('save_as_template', VmSaveView), ('save_as_template', VmSaveView),
...@@ -716,9 +863,11 @@ vm_ops = OrderedDict([ ...@@ -716,9 +863,11 @@ vm_ops = OrderedDict([
('recover', VmOperationView.factory( ('recover', VmOperationView.factory(
op='recover', icon='medkit', effect='warning')), op='recover', icon='medkit', effect='warning')),
('destroy', VmOperationView.factory( ('destroy', VmOperationView.factory(
extra_bases=[TokenOperationView],
op='destroy', icon='remove', effect='danger')), op='destroy', icon='remove', effect='danger')),
('create_disk', VmCreateDiskView), ('create_disk', VmCreateDiskView),
('download_disk', VmDownloadDiskView), ('download_disk', VmDownloadDiskView),
('renew', VmRenewView),
]) ])
...@@ -2136,10 +2285,11 @@ class VmMassDelete(LoginRequiredMixin, View): ...@@ -2136,10 +2285,11 @@ class VmMassDelete(LoginRequiredMixin, View):
return redirect(next if next else reverse_lazy('dashboard.index')) return redirect(next if next else reverse_lazy('dashboard.index'))
class LeaseCreate(LoginRequiredMixin, SuperuserRequiredMixin, class LeaseCreate(LoginRequiredMixin, PermissionRequiredMixin,
SuccessMessageMixin, CreateView): SuccessMessageMixin, CreateView):
model = Lease model = Lease
form_class = LeaseForm form_class = LeaseForm
permission_required = 'vm.create_leases'
template_name = "dashboard/lease-create.html" template_name = "dashboard/lease-create.html"
success_message = _("Successfully created a new lease.") success_message = _("Successfully created a new lease.")
...@@ -2147,6 +2297,10 @@ class LeaseCreate(LoginRequiredMixin, SuperuserRequiredMixin, ...@@ -2147,6 +2297,10 @@ class LeaseCreate(LoginRequiredMixin, SuperuserRequiredMixin,
return reverse_lazy("dashboard.views.template-list") return reverse_lazy("dashboard.views.template-list")
class LeaseAclUpdateView(AclUpdateView):
model = Lease
class LeaseDetail(LoginRequiredMixin, SuperuserRequiredMixin, class LeaseDetail(LoginRequiredMixin, SuperuserRequiredMixin,
SuccessMessageMixin, UpdateView): SuccessMessageMixin, UpdateView):
model = Lease model = Lease
...@@ -2154,6 +2308,12 @@ class LeaseDetail(LoginRequiredMixin, SuperuserRequiredMixin, ...@@ -2154,6 +2308,12 @@ class LeaseDetail(LoginRequiredMixin, SuperuserRequiredMixin,
template_name = "dashboard/lease-edit.html" template_name = "dashboard/lease-edit.html"
success_message = _("Successfully modified lease.") success_message = _("Successfully modified lease.")
def get_context_data(self, *args, **kwargs):
obj = self.get_object()
context = super(LeaseDetail, self).get_context_data(*args, **kwargs)
context['acl'] = get_vm_acl_data(obj)
return context
def get_success_url(self): def get_success_url(self):
return reverse_lazy("dashboard.views.lease-detail", kwargs=self.kwargs) return reverse_lazy("dashboard.views.lease-detail", kwargs=self.kwargs)
...@@ -2295,163 +2455,6 @@ class TransferOwnershipView(LoginRequiredMixin, DetailView): ...@@ -2295,163 +2455,6 @@ class TransferOwnershipView(LoginRequiredMixin, DetailView):
kwargs={'pk': obj.pk})) kwargs={'pk': obj.pk}))
class AbstractVmFunctionView(AccessMixin, View):
"""Abstract instance-action view.
User can do the action with a valid token or if has at least required_level
ACL level for the instance.
Children should at least implement/add template_name, success_message,
url_name, and do_action().
"""
token_max_age = 3 * 24 * 3600
required_level = 'owner'
success_message = _("Failed to perform requested action.")
@classmethod
def check_acl(cls, instance, user):
if not instance.has_level(user, cls.required_level):
raise PermissionDenied()
@classmethod
def get_salt(cls):
return unicode(cls)
@classmethod
def get_token(cls, instance, user, *args):
t = tuple([getattr(i, 'pk', i) for i in [instance, user] + list(args)])
return signing.dumps(t, salt=cls.get_salt(), compress=True)
@classmethod
def get_token_url(cls, instance, user, *args):
key = cls.get_token(instance, user, *args)
args = (instance.pk, key) + args
return reverse(cls.url_name, args=args)
# this wont work, CBVs suck: reverse(cls.as_view(), args=args)
def get_template_names(self):
return [self.template_name]
def get(self, request, pk, key=None, *args, **kwargs):
class LoginNeeded(Exception):
pass
pk = int(pk)
instance = get_object_or_404(Instance, pk=pk)
try:
if key:
logger.debug('Confirm dialog for token %s.', key)
try:
self.validate_key(pk, key)
except signing.SignatureExpired:
messages.error(request, _(
'The token has expired, please log in.'))
raise LoginNeeded()
self.key = key
else:
if not request.user.is_authenticated():
raise LoginNeeded()
self.check_acl(instance, request.user)
except LoginNeeded:
return redirect_to_login(request.get_full_path(),
self.get_login_url(),
self.get_redirect_field_name())
except SuspiciousOperation as e:
messages.error(request, _('This token is invalid.'))
logger.warning('This token %s is invalid. %s', key, unicode(e))
raise PermissionDenied()
return render(request, self.get_template_names(),
self.get_context(instance))
def post(self, request, pk, key=None, *args, **kwargs):
class LoginNeeded(Exception):
pass
pk = int(pk)
instance = get_object_or_404(Instance, pk=pk)
try:
if not request.user.is_authenticated() and key:
try:
user = self.validate_key(pk, key)
except signing.SignatureExpired:
messages.error(request, _(
'The token has expired, please log in.'))
raise LoginNeeded()
self.key = key
else:
user = request.user
self.check_acl(instance, request.user)
except LoginNeeded:
return redirect_to_login(request.get_full_path(),
self.get_login_url(),
self.get_redirect_field_name())
except SuspiciousOperation as e:
messages.error(request, _('This token is invalid.'))
logger.warning('This token %s is invalid. %s', key, unicode(e))
raise PermissionDenied()
if self.do_action(instance, user):
messages.success(request, self.success_message)
else:
messages.error(request, self.fail_message)
return HttpResponseRedirect(instance.get_absolute_url())
def validate_key(self, pk, key):
"""Get object based on signed token.
"""
try:
data = signing.loads(key, salt=self.get_salt())
logger.debug('Token data: %s', unicode(data))
instance, user = data
logger.debug('Extracted token data: instance: %s, user: %s',
unicode(instance), unicode(user))
except (signing.BadSignature, ValueError, TypeError) as e:
logger.warning('Tried invalid token. Token: %s, user: %s. %s',
key, unicode(self.request.user), unicode(e))
raise SuspiciousOperation()
try:
instance, user = signing.loads(key, max_age=self.token_max_age,
salt=self.get_salt())
logger.debug('Extracted non-expired token data: %s, %s',
unicode(instance), unicode(user))
except signing.BadSignature as e:
raise signing.SignatureExpired()
if pk != instance:
logger.debug('pk (%d) != instance (%d)', pk, instance)
raise SuspiciousOperation()
user = User.objects.get(pk=user)
return user
def do_action(self, instance, user): # noqa
raise NotImplementedError('Please override do_action(instance, user)')
def get_context(self, instance):
context = {'instance': instance}
if getattr(self, 'key', None) is not None:
context['key'] = self.key
return context
class VmRenewView(AbstractVmFunctionView):
"""User can renew an instance."""
template_name = 'dashboard/confirm/base-renew.html'
success_message = _("Virtual machine is successfully renewed.")
url_name = 'dashboard.views.vm-renew'
def get_context(self, instance):
context = super(VmRenewView, self).get_context(instance)
(context['time_of_suspend'],
context['time_of_delete']) = instance.get_renew_times()
return context
def do_action(self, instance, user):
instance.renew(user=user)
logger.info('Instance %s renewed by %s.', unicode(instance),
unicode(user))
return True
class TransferOwnershipConfirmView(LoginRequiredMixin, View): class TransferOwnershipConfirmView(LoginRequiredMixin, View):
"""User can accept an ownership offer.""" """User can accept an ownership offer."""
......
...@@ -225,6 +225,8 @@ def node_activity(code_suffix, node, task_uuid=None, user=None): ...@@ -225,6 +225,8 @@ def node_activity(code_suffix, node, task_uuid=None, user=None):
@worker_ready.connect() @worker_ready.connect()
def cleanup(conf=None, **kwargs): def cleanup(conf=None, **kwargs):
# TODO check if other manager workers are running # TODO check if other manager workers are running
from celery.task.control import discard_all
discard_all()
msg_txt = ugettext_noop("Manager is restarted, activity is cleaned up. " msg_txt = ugettext_noop("Manager is restarted, activity is cleaned up. "
"You can try again now.") "You can try again now.")
message = create_readable(msg_txt, msg_txt) message = create_readable(msg_txt, msg_txt)
......
...@@ -18,12 +18,14 @@ ...@@ -18,12 +18,14 @@
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
from datetime import timedelta, datetime from datetime import timedelta, datetime
from django.db.models import Model, CharField, IntegerField from django.db.models import Model, CharField, IntegerField, permalink
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.timesince import timeuntil from django.utils.timesince import timeuntil
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from acl.models import AclBase
ARCHITECTURES = (('x86_64', 'x86-64 (64 bit)'), ARCHITECTURES = (('x86_64', 'x86-64 (64 bit)'),
('i686', 'x86 (32 bit)')) ('i686', 'x86 (32 bit)'))
...@@ -66,13 +68,18 @@ class NamedBaseResourceConfig(BaseResourceConfigModel, TimeStampedModel): ...@@ -66,13 +68,18 @@ class NamedBaseResourceConfig(BaseResourceConfigModel, TimeStampedModel):
return self.name return self.name
class Lease(Model): class Lease(AclBase):
"""Lease times for VM instances. """Lease times for VM instances.
Specifies a time duration until suspension and deletion of a VM Specifies a time duration until suspension and deletion of a VM
instance. instance.
""" """
ACL_LEVELS = (
('user', _('user')), # use this lease
('operator', _('operator')), # share this lease
('owner', _('owner')), # change this lease
)
name = CharField(max_length=100, unique=True, name = CharField(max_length=100, unique=True,
verbose_name=_('name')) verbose_name=_('name'))
suspend_interval_seconds = IntegerField( suspend_interval_seconds = IntegerField(
...@@ -88,6 +95,9 @@ class Lease(Model): ...@@ -88,6 +95,9 @@ class Lease(Model):
app_label = 'vm' app_label = 'vm'
db_table = 'vm_lease' db_table = 'vm_lease'
ordering = ['name', ] ordering = ['name', ]
permissions = (
('create_leases', _('Can create new leases.')),
)
@property @property
def suspend_interval(self): def suspend_interval(self):
...@@ -141,6 +151,10 @@ class Lease(Model): ...@@ -141,6 +151,10 @@ class Lease(Model):
's': self.get_readable_suspend_time(), 's': self.get_readable_suspend_time(),
'r': self.get_readable_delete_time()} 'r': self.get_readable_delete_time()}
@permalink
def get_absolute_url(self):
return ('dashboard.views.lease-detail', None, {'pk': self.pk})
class Trait(Model): class Trait(Model):
name = CharField(max_length=50, verbose_name=_('name')) name = CharField(max_length=50, verbose_name=_('name'))
......
...@@ -439,10 +439,7 @@ class Instance(AclBase, VirtualMachineDescModel, StatusModel, OperatedMixin, ...@@ -439,10 +439,7 @@ class Instance(AclBase, VirtualMachineDescModel, StatusModel, OperatedMixin,
for cps in customized_params] for cps in customized_params]
def clean(self, *args, **kwargs): def clean(self, *args, **kwargs):
if self.time_of_suspend is None: self.time_of_suspend, self.time_of_delete = self.get_renew_times()
self._do_renew(which='suspend')
if self.time_of_delete is None:
self._do_renew(which='delete')
super(Instance, self).clean(*args, **kwargs) super(Instance, self).clean(*args, **kwargs)
def manual_state_change(self, new_state="NOSTATE", reason=None, user=None): def manual_state_change(self, new_state="NOSTATE", reason=None, user=None):
...@@ -715,36 +712,14 @@ class Instance(AclBase, VirtualMachineDescModel, StatusModel, OperatedMixin, ...@@ -715,36 +712,14 @@ class Instance(AclBase, VirtualMachineDescModel, StatusModel, OperatedMixin,
else: else:
return False return False
def get_renew_times(self): def get_renew_times(self, lease=None):
"""Returns new suspend and delete times if renew would be called. """Returns new suspend and delete times if renew would be called.
""" """
if lease is None:
lease = self.lease
return ( return (
timezone.now() + self.lease.suspend_interval, timezone.now() + lease.suspend_interval,
timezone.now() + self.lease.delete_interval) timezone.now() + lease.delete_interval)
def _do_renew(self, which='both'):
"""Set expiration times to renewed values.
"""
time_of_suspend, time_of_delete = self.get_renew_times()
if which in ('suspend', 'both'):
self.time_of_suspend = time_of_suspend
if which in ('delete', 'both'):
self.time_of_delete = time_of_delete
def renew(self, which='both', base_activity=None, user=None):
"""Renew virtual machine instance leases.
"""
if base_activity is None:
act_ctx = instance_activity(code_suffix='renew', instance=self,
user=user)
else:
act_ctx = base_activity.sub_activity('renew')
with act_ctx:
if which not in ('suspend', 'delete', 'both'):
raise ValueError('No such expiration type.')
self._do_renew(which)
self.save()
def change_password(self, user=None): def change_password(self, user=None):
"""Generate new password for the vm """Generate new password for the vm
......
...@@ -189,6 +189,7 @@ class DeployOperation(InstanceOperation): ...@@ -189,6 +189,7 @@ class DeployOperation(InstanceOperation):
self.instance.deploy_disks() self.instance.deploy_disks()
# Deploy VM on remote machine # Deploy VM on remote machine
if self.instance.state not in ['PAUSED']:
with activity.sub_activity('deploying_vm') as deploy_act: with activity.sub_activity('deploying_vm') as deploy_act:
deploy_act.result = self.instance.deploy_vm(timeout=timeout) deploy_act.result = self.instance.deploy_vm(timeout=timeout)
...@@ -200,7 +201,7 @@ class DeployOperation(InstanceOperation): ...@@ -200,7 +201,7 @@ class DeployOperation(InstanceOperation):
with activity.sub_activity('booting'): with activity.sub_activity('booting'):
self.instance.resume_vm(timeout=timeout) self.instance.resume_vm(timeout=timeout)
self.instance.renew(which='both', base_activity=activity) self.instance.renew(parent_activity=activity)
register_operation(DeployOperation) register_operation(DeployOperation)
...@@ -613,12 +614,30 @@ class WakeUpOperation(InstanceOperation): ...@@ -613,12 +614,30 @@ class WakeUpOperation(InstanceOperation):
self.instance.deploy_net() self.instance.deploy_net()
# Renew vm # Renew vm
self.instance.renew(which='both', base_activity=activity) self.instance.renew(parent_activity=activity)
register_operation(WakeUpOperation) register_operation(WakeUpOperation)
class RenewOperation(InstanceOperation):
activity_code_suffix = 'renew'
id = 'renew'
name = _("renew")
description = _("Renew expiration times")
acl_level = "operator"
required_perms = ()
concurrency_check = False
def _operation(self, lease=None):
(self.instance.time_of_suspend,
self.instance.time_of_delete) = self.instance.get_renew_times(lease)
self.instance.save()
register_operation(RenewOperation)
class NodeOperation(Operation): class NodeOperation(Operation):
async_operation = abortable_async_node_operation async_operation = abortable_async_node_operation
host_cls = Node host_cls = Node
......
Deploy Deploy
======== ======
This is where you describe how the project is deployed in production. This tutorial describes the installation of a production environment. To
have a fully working environment, you have to set up the other components
as well. The full procedure is included in the :doc:`Puppet recipes
<puppet>` available for CIRCLE Cloud.
This component should normally deployed to a single head node.
This is the web-based entry point to the end users, and also the manager of
the compute and storage nodes.
Preparation
-----------
To get the project running, launch a new Ubuntu 14.04 machine, and
log in to it over SSH.
.. warning::
If the first character of the hostname should not be a digit, because
RabbitMQ won't work with it.
The machine should have an :abbr:`fqdn (fully qualified domain name)`,
which shoud be correctly printed by :kbd:`hostname -f`. You can achieve
this with an IP address (e.g. 127.0.1.1) in :file:`/etc/hosts` having the
short hostname as first, and the fqdn as second alias).
Setting up required software
----------------------------
Update the package lists, and install the required system software::
sudo apt-get update
sudo apt-get install --yes virtualenvwrapper postgresql git \
python-pip rabbitmq-server libpq-dev python-dev ntp memcached \
libmemcached-dev gettext wget pwgen nginx
Set up *PostgreSQL* to listen on localhost and restart it::
sudo sed -i /etc/postgresql/9.1/main/postgresql.conf -e '/#listen_addresses/ s/^#//'
sudo /etc/init.d/postgresql restart
Also, create a new database and user::
pwgen 12 >pgpw
sudo -u postgres createuser -S -D -R circle
sudo -u postgres psql <<<"ALTER USER circle WITH PASSWORD '$(cat pgpw)';"
sudo -u postgres createdb circle -O circle
Configure RabbitMQ: remove the guest user, add virtual host and user with
proper permissions::
pwgen 12 >rmqpw
sudo rabbitmqctl delete_user guest
sudo rabbitmqctl add_vhost circle
sudo rabbitmqctl add_user cloud $(cat rmqpw)
sudo rabbitmqctl set_permissions -p circle cloud '.*' '.*' '.*'
Set up nginx to serve the CIRCLE portal. ::
sudo tee /etc/nginx/conf.d/default.conf <<END
ignore_invalid_headers on;
server {
listen 443 ssl default;
ssl on;
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
location /static {
alias ${PWD}/circle/static_collected; # your Django project's static files
}
location / {
uwsgi_pass unix:///tmp/uwsgi.sock;
include /etc/nginx/uwsgi_params; # or the uwsgi_params you installed manually
}
location /vnc/ {
proxy_pass http://localhost:9999;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
# WebSocket support (nginx 1.4)
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
}
}
server {
listen 80 default;
rewrite ^ https://\$host/; # permanent;
}
END
sudo /etc/init.d/nginx restart
.. warning::
For a production deployment, you should use certificates issued by a
recognized certificate authority. Until you get it, you can use a
self-signed one automatically generated by the package.
Setting up Circle itself
------------------------
Clone the git repository::
git clone https://git.ik.bme.hu/circle/cloud.git circle
Set up *virtualenvwrapper* and the *virtual Python environment* for the
project::
source /etc/bash_completion.d/virtualenvwrapper
mkvirtualenv circle
Set up default Circle configuration and activate the virtual environment::
cat >>/home/cloud/.virtualenvs/circle/bin/postactivate <<END
export DJANGO_SETTINGS_MODULE=circle.settings.production
export DJANGO_DB_HOST=localhost
export DJANGO_DB_PASSWORD=$(cat pgpw)
export DJANGO_FIREWALL_SETTINGS='{"dns_ip": "152.66.243.60", "dns_hostname":
"localhost", "dns_ttl": "300", "reload_sleep": "10",
"rdns_ip": "152.66.243.60", "default_vlangroup": "publikus"}'
export AMQP_URI='amqp://cloud:$(cat rmqpw)@localhost:5672/circle'
export CACHE_URI='pylibmc://127.0.0.1:11211/'
END
workon circle
cd ~/circle
You should change DJANGO_FIREWALL_SETTINGS to your needs.
Install the required Python libraries to the virtual environment::
pip install -r requirements.txt
Sync the database and create a superuser::
circle/manage.py syncdb --all --noinput
circle/manage.py migrate --fake
circle/manage.py createsuperuser
Copy the init files to Upstart's config directory and start the manager and
the portal application server::
sudo cp miscellaneous/mancelery.conf /etc/init/
sudo start mancelery
sudo cp miscellaneous/portal-uwsgi.conf /etc/init/
sudo start portal-uwsgi
...@@ -3,31 +3,40 @@ Installation of a development machine ...@@ -3,31 +3,40 @@ Installation of a development machine
.. highlight:: bash .. highlight:: bash
This tutorial describes the installation of a development environment. To
have a fully working environment, you have to set up the other components
as well. The full procedure is included in the :doc:`Puppet recipes
</puppet>` available for CIRCLE Cloud.
Preparation Preparation
----------- -----------
To get the project running on a development machine, create a new Ubuntu 12.04 To get the project running on a development machine, launch a new Ubuntu
instance, and log in to it over SSH. 14.04 machine, and log in to it over SSH.
To use *git* over *SSH*, we advise enabling SSH *agent forwarding*. .. info::
On your personal computer check if *ssh-agent* is running (the command should To use *git* over *SSH*, we advise enabling SSH *agent forwarding*.
print a process id):: On your terminal computer check if *ssh-agent* is running (the command
should print a process id)::
$ echo $SSH_AGENT_PID $ echo $SSH_AGENT_PID
1234 1234
If it is not running, you should set up your login manager or some other If it is not running, you can configure your dektop environment to
solution to automatically launch it. automatically launch it.
Add your private key to the agent (if it is not added by your desktop
environment)::
Add your private key to the agent (if it is not added by your desktop ssh-add [~/.ssh/path_to_id_rsa]
environment)::
$ ssh-add [~/.ssh/path_to_id_rsa] You can read and write all repositories over https, but you will have to
provide username and password for every push command.
Log in to the new vm. The :kbd:`-A` switch enables agent forwarding:: Log in to the new vm. The :kbd:`-A` switch enables agent forwarding::
$ ssh -A cloud@host ssh -A cloud@host
You can check agent forwarding on the vm:: You can check agent forwarding on the vm::
...@@ -38,55 +47,55 @@ You can check agent forwarding on the vm:: ...@@ -38,55 +47,55 @@ You can check agent forwarding on the vm::
If the first character of the hostname of the vm is a digit, you have to If the first character of the hostname of the vm is a digit, you have to
change it, because RabbitMQ won't work with it. :: change it, because RabbitMQ won't work with it. ::
$ old=$(hostname) old=$(hostname)
$ new=c-${old} new=c-${old}
$ sudo tee /etc/hostname <<<$new sudo tee /etc/hostname <<<$new
$ sudo hostname $new sudo hostname $new
$ sudo sed -i /etc/hosts -e "s/$old/$new/g" sudo sed -i /etc/hosts -e "s/$old/$new/g"
Setting up required software Setting up required software
---------------------------- ----------------------------
Update the package lists, and install the required system software:: Update the package lists, and install the required system software::
$ sudo apt-get update sudo apt-get update
$ sudo apt-get install --yes virtualenvwrapper postgresql git \ sudo apt-get install --yes virtualenvwrapper postgresql git \
python-pip rabbitmq-server libpq-dev python-dev ntp memcached \ python-pip rabbitmq-server libpq-dev python-dev ntp memcached \
libmemcached-dev libmemcached-dev
Set up *PostgreSQL* to listen on localhost and restart it:: Set up *PostgreSQL* to listen on localhost and restart it::
$ sudo sed -i /etc/postgresql/9.1/main/postgresql.conf -e '/#listen_addresses/ s/^#//' sudo sed -i /etc/postgresql/9.1/main/postgresql.conf -e '/#listen_addresses/ s/^#//'
$ sudo /etc/init.d/postgresql restart sudo /etc/init.d/postgresql restart
Also, create a new database and user:: Also, create a new database and user::
$ sudo -u postgres createuser -S -D -R circle sudo -u postgres createuser -S -D -R circle
$ sudo -u postgres psql <<<"ALTER USER circle WITH PASSWORD 'circle';" sudo -u postgres psql <<<"ALTER USER circle WITH PASSWORD 'circle';"
$ sudo -u postgres createdb circle -O circle sudo -u postgres createdb circle -O circle
Configure RabbitMQ: remove the guest user, add virtual host and user with Configure RabbitMQ: remove the guest user, add virtual host and user with
proper permissions:: proper permissions::
$ sudo rabbitmqctl delete_user guest sudo rabbitmqctl delete_user guest
$ sudo rabbitmqctl add_vhost circle sudo rabbitmqctl add_vhost circle
$ sudo rabbitmqctl add_user cloud password sudo rabbitmqctl add_user cloud password
$ sudo rabbitmqctl set_permissions -p circle cloud '.*' '.*' '.*' sudo rabbitmqctl set_permissions -p circle cloud '.*' '.*' '.*'
Enable SSH server to accept your name and address from your environment:: Enable SSH server to accept your name and address from your environment::
$ sudo sed -i /etc/ssh/sshd_config -e '$ a AcceptEnv GIT_*' sudo sed -i /etc/ssh/sshd_config -e '$ a AcceptEnv GIT_*'
$ sudo /etc/init.d/ssh reload sudo /etc/init.d/ssh reload
You should set these vars in your **local** profile:: You should set these vars in your **local** profile::
$ cat >>~/.profile <<'END' cat >>~/.profile <<'END'
export GIT_AUTHOR_NAME="Your Name" export GIT_AUTHOR_NAME="Your Name"
export GIT_AUTHOR_EMAIL="your.address@example.org" export GIT_AUTHOR_EMAIL="your.address@example.org"
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME" export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL" export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
END END
$ source ~/.profile source ~/.profile
Allow sending it in your **local** ssh configuration:: Allow sending it in your **local** ssh configuration::
...@@ -100,17 +109,23 @@ Setting up Circle itself ...@@ -100,17 +109,23 @@ Setting up Circle itself
Clone the git repository:: Clone the git repository::
$ git clone git@git.cloud.ik.bme.hu:circle/cloud.git circle git clone https://git.ik.bme.hu/circle/cloud.git circle
If you want to push back any modifications, it is possible to set SSH as the
push protocol::
cd circle
git remote set-url --push origin git@git.ik.bme.hu:circle/cloud.git
Set up *virtualenvwrapper* and the *virtual Python environment* for the Set up *virtualenvwrapper* and the *virtual Python environment* for the
project:: project::
$ source /etc/bash_completion.d/virtualenvwrapper source /etc/bash_completion.d/virtualenvwrapper
$ mkvirtualenv circle mkvirtualenv circle
Set up default Circle configuration and activate the virtual environment:: Set up default Circle configuration and activate the virtual environment::
$ cat >>/home/cloud/.virtualenvs/circle/bin/postactivate <<END cat >>/home/cloud/.virtualenvs/circle/bin/postactivate <<END
export DJANGO_SETTINGS_MODULE=circle.settings.local export DJANGO_SETTINGS_MODULE=circle.settings.local
export DJANGO_DB_HOST=localhost export DJANGO_DB_HOST=localhost
export DJANGO_DB_PASSWORD=circle export DJANGO_DB_PASSWORD=circle
...@@ -120,32 +135,32 @@ Set up default Circle configuration and activate the virtual environment:: ...@@ -120,32 +135,32 @@ Set up default Circle configuration and activate the virtual environment::
export AMQP_URI='amqp://cloud:password@localhost:5672/circle' export AMQP_URI='amqp://cloud:password@localhost:5672/circle'
export CACHE_URI='pylibmc://127.0.0.1:11211/' export CACHE_URI='pylibmc://127.0.0.1:11211/'
END END
$ workon circle workon circle
$ cd ~/circle cd ~/circle
Install the required Python libraries to the virtual environment:: Install the required Python libraries to the virtual environment::
$ pip install -r requirements/local.txt pip install -r requirements/local.txt
Sync the database and create a superuser:: Sync the database and create a superuser::
$ circle/manage.py syncdb --all --noinput circle/manage.py syncdb --all --noinput
$ circle/manage.py migrate --fake circle/manage.py migrate --fake
$ circle/manage.py createsuperuser --username=test --email=test@example.org circle/manage.py createsuperuser --username=test --email=test@example.org
You can now start the development server:: You can now start the development server::
$ circle/manage.py runserver '[::]:8080' circle/manage.py runserver '[::]:8080'
You will also need to run a local Celery worker:: You will also need to run a local Celery worker::
$ circle/manage.py celery worker -A manager.mancelery circle/manage.py celery worker -A manager.mancelery
.. note:: .. note::
You might run the Celery worker (and also the development server) in GNU You might run the Celery worker (and also the development server) in GNU
Screen, or use Upstart:: Screen, or use Upstart::
$ sudo cp miscellaneous/mancelery.conf /etc/init/ sudo cp miscellaneous/mancelery.conf /etc/init/
$ sudo start mancelery sudo start mancelery
Building documentation Building documentation
---------------------- ----------------------
...@@ -153,14 +168,14 @@ Building documentation ...@@ -153,14 +168,14 @@ Building documentation
To build the *docs*, install *make*, go to the docs folder, and run the building To build the *docs*, install *make*, go to the docs folder, and run the building
process. :: process. ::
$ sudo apt-get install make sudo apt-get install make
$ cd ~/circle/docs/ cd ~/circle/docs/
$ make html make html
You might also want to serve the generated docs with Python's development You might also want to serve the generated docs with Python's development
server:: server::
$ (cd _build/html && python -m SimpleHTTPServer 8080) (cd _build/html && python -m SimpleHTTPServer 8080)
Configuring vim Configuring vim
--------------- ---------------
...@@ -168,16 +183,16 @@ Configuring vim ...@@ -168,16 +183,16 @@ Configuring vim
To follow the coding style of the project more easily, you might want to To follow the coding style of the project more easily, you might want to
configure vim like we do:: configure vim like we do::
$ mkdir -p ~/.vim/autoload ~/.vim/bundle mkdir -p ~/.vim/autoload ~/.vim/bundle
$ curl -Sso ~/.vim/autoload/pathogen.vim \ curl -Sso ~/.vim/autoload/pathogen.vim \
https://raw.githubusercontent.com/tpope/vim-pathogen/master/autoload/pathogen.vim https://raw.github.com/tpope/vim-pathogen/master/autoload/pathogen.vim
$ cd ~/.vim; mkdir -p bundle; cd bundle && git clone \ cd ~/.vim; mkdir -p bundle; cd bundle && git clone \
git://github.com/klen/python-mode.git git://github.com/klen/python-mode.git
$ cat >>~/.vimrc <<END cat >>~/.vimrc <<END
filetype off filetype off
call pathogen#infect() call pathogen#infect()
call pathogen#helptags() call pathogen#helptags()
filetype plugin indent on filetype plugin indent on
syntax on syntax on
END END
$ sudo pip install pyflakes rope pep8 mccabe sudo pip install pyflakes rope pep8 mccabe
...@@ -8,7 +8,7 @@ django-braces==1.4.0 ...@@ -8,7 +8,7 @@ django-braces==1.4.0
django-celery==3.1.10 django-celery==3.1.10
django-crispy-forms==1.4.0 django-crispy-forms==1.4.0
django-model-utils==2.0.3 django-model-utils==2.0.3
django-sizefield==0.4 django-sizefield==0.5
django-sshkey==2.2.0 django-sshkey==2.2.0
django-statici18n==1.1 django-statici18n==1.1
django-tables2==0.15.0 django-tables2==0.15.0
......
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