Implements reusable tab components.
Adds a fully-featured tabbed interface component, with support for dynamic loading via AJAX and class-based views to handle the heavy lifting. Cleaned up the instance detail view as a POC. Implements blueprint reusable-tabs. Change-Id: I3adbf1f1e4e95ae210d36477031a433c13f3d77c
This commit is contained in:
parent
27915bd5e3
commit
fca0b641a7
@ -84,6 +84,7 @@ In-depth documentation for Horizon and it's APIs.
|
|||||||
ref/run_tests
|
ref/run_tests
|
||||||
ref/horizon
|
ref/horizon
|
||||||
ref/tables
|
ref/tables
|
||||||
|
ref/tabs
|
||||||
ref/users
|
ref/users
|
||||||
ref/forms
|
ref/forms
|
||||||
ref/views
|
ref/views
|
||||||
|
37
docs/source/ref/tabs.rst
Normal file
37
docs/source/ref/tabs.rst
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
==========================
|
||||||
|
Horizon Tabs and TabGroups
|
||||||
|
==========================
|
||||||
|
|
||||||
|
.. module:: horizon.tabs
|
||||||
|
|
||||||
|
Horizon includes a set of reusable components for programmatically
|
||||||
|
building tabbed interfaces with fancy features like dynamic AJAX loading
|
||||||
|
and nearly effortless templating and styling.
|
||||||
|
|
||||||
|
Tab Groups
|
||||||
|
==========
|
||||||
|
|
||||||
|
For any tabbed interface, your fundamental element is the tab group which
|
||||||
|
contains all your tabs. This class provides a dead-simple API for building
|
||||||
|
tab groups and encapsulates all the necessary logic behind the scenes.
|
||||||
|
|
||||||
|
.. autoclass:: TabGroup
|
||||||
|
:members:
|
||||||
|
|
||||||
|
Tabs
|
||||||
|
====
|
||||||
|
|
||||||
|
The tab itself is the discrete unit for a tab group, representing one
|
||||||
|
view of data.
|
||||||
|
|
||||||
|
.. autoclass:: Tab
|
||||||
|
:members:
|
||||||
|
|
||||||
|
TabView
|
||||||
|
=======
|
||||||
|
|
||||||
|
There is also a useful and simple generic class-based view for handling
|
||||||
|
the display of a :class:`~horizon.tabs.TabGroup` class.
|
||||||
|
|
||||||
|
.. autoclass:: TabView
|
||||||
|
:members:
|
@ -0,0 +1,74 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# Copyright 2012 Nebula, Inc.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
|
from horizon import api
|
||||||
|
from horizon import exceptions
|
||||||
|
from horizon import tabs
|
||||||
|
|
||||||
|
|
||||||
|
class OverviewTab(tabs.Tab):
|
||||||
|
name = _("Overview")
|
||||||
|
slug = "overview"
|
||||||
|
template_name = ("nova/instances_and_volumes/instances/"
|
||||||
|
"_detail_overview.html")
|
||||||
|
|
||||||
|
def get_context_data(self, request):
|
||||||
|
return {"instance": self.tab_group.kwargs['instance']}
|
||||||
|
|
||||||
|
|
||||||
|
class LogTab(tabs.Tab):
|
||||||
|
name = _("Log")
|
||||||
|
slug = "log"
|
||||||
|
template_name = "nova/instances_and_volumes/instances/_detail_log.html"
|
||||||
|
preload = False
|
||||||
|
|
||||||
|
def get_context_data(self, request):
|
||||||
|
instance = self.tab_group.kwargs['instance']
|
||||||
|
try:
|
||||||
|
data = api.server_console_output(request, instance.id)
|
||||||
|
except:
|
||||||
|
data = _('Unable to get log for instance "%s".') % instance.id
|
||||||
|
exceptions.handle(request, ignore=True)
|
||||||
|
return {"instance": instance,
|
||||||
|
"console_log": data}
|
||||||
|
|
||||||
|
|
||||||
|
class VNCTab(tabs.Tab):
|
||||||
|
name = _("VNC")
|
||||||
|
slug = "vnc"
|
||||||
|
template_name = "nova/instances_and_volumes/instances/_detail_vnc.html"
|
||||||
|
preload = False
|
||||||
|
|
||||||
|
def get_context_data(self, request):
|
||||||
|
instance = self.tab_group.kwargs['instance']
|
||||||
|
try:
|
||||||
|
console = api.nova.server_vnc_console(request, instance.id)
|
||||||
|
vnc_url = "%s&title=%s(%s)" % (console.url,
|
||||||
|
getattr(instance, "name", ""),
|
||||||
|
instance.id)
|
||||||
|
except:
|
||||||
|
vnc_url = None
|
||||||
|
exceptions.handle(request,
|
||||||
|
_('Unable to get vnc console for '
|
||||||
|
'instance "%s".') % instance.id)
|
||||||
|
return {'vnc_url': vnc_url}
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceDetailTabs(tabs.TabGroup):
|
||||||
|
slug = "instance_details"
|
||||||
|
tabs = (OverviewTab, LogTab, VNCTab)
|
@ -25,6 +25,7 @@ from novaclient import exceptions as nova_exceptions
|
|||||||
|
|
||||||
from horizon import api
|
from horizon import api
|
||||||
from horizon import test
|
from horizon import test
|
||||||
|
from .tabs import InstanceDetailTabs
|
||||||
|
|
||||||
|
|
||||||
INDEX_URL = reverse('horizon:nova:instances_and_volumes:index')
|
INDEX_URL = reverse('horizon:nova:instances_and_volumes:index')
|
||||||
@ -199,36 +200,38 @@ class InstanceViewTests(test.TestCase):
|
|||||||
res = self.client.post(INDEX_URL, formData)
|
res = self.client.post(INDEX_URL, formData)
|
||||||
self.assertRedirectsNoFollow(res, INDEX_URL)
|
self.assertRedirectsNoFollow(res, INDEX_URL)
|
||||||
|
|
||||||
def test_instance_console(self):
|
def test_instance_log(self):
|
||||||
server = self.servers.first()
|
server = self.servers.first()
|
||||||
CONSOLE_OUTPUT = 'output'
|
CONSOLE_OUTPUT = 'output'
|
||||||
|
|
||||||
self.mox.StubOutWithMock(api, 'server_console_output')
|
self.mox.StubOutWithMock(api, 'server_console_output')
|
||||||
api.server_console_output(IsA(http.HttpRequest),
|
api.server_console_output(IsA(http.HttpRequest),
|
||||||
server.id,
|
server.id).AndReturn(CONSOLE_OUTPUT)
|
||||||
tail_length=None).AndReturn(CONSOLE_OUTPUT)
|
|
||||||
self.mox.ReplayAll()
|
self.mox.ReplayAll()
|
||||||
|
|
||||||
url = reverse('horizon:nova:instances_and_volumes:instances:console',
|
url = reverse('horizon:nova:instances_and_volumes:instances:console',
|
||||||
args=[server.id])
|
args=[server.id])
|
||||||
res = self.client.get(url)
|
tg = InstanceDetailTabs(self.request)
|
||||||
|
qs = "?%s=%s" % (tg.param_name, tg.get_tab("log").get_id())
|
||||||
|
res = self.client.get(url + qs)
|
||||||
self.assertIsInstance(res, http.HttpResponse)
|
self.assertIsInstance(res, http.HttpResponse)
|
||||||
self.assertContains(res, CONSOLE_OUTPUT)
|
self.assertContains(res, CONSOLE_OUTPUT)
|
||||||
|
|
||||||
def test_instance_console_exception(self):
|
def test_instance_log_exception(self):
|
||||||
server = self.servers.first()
|
server = self.servers.first()
|
||||||
|
|
||||||
self.mox.StubOutWithMock(api, 'server_console_output')
|
self.mox.StubOutWithMock(api, 'server_console_output')
|
||||||
exc = nova_exceptions.ClientException(500)
|
exc = nova_exceptions.ClientException(500)
|
||||||
api.server_console_output(IsA(http.HttpRequest),
|
api.server_console_output(IsA(http.HttpRequest),
|
||||||
server.id,
|
server.id).AndRaise(exc)
|
||||||
tail_length=None).AndRaise(exc)
|
|
||||||
self.mox.ReplayAll()
|
self.mox.ReplayAll()
|
||||||
|
|
||||||
url = reverse('horizon:nova:instances_and_volumes:instances:console',
|
url = reverse('horizon:nova:instances_and_volumes:instances:console',
|
||||||
args=[server.id])
|
args=[server.id])
|
||||||
res = self.client.get(url)
|
tg = InstanceDetailTabs(self.request)
|
||||||
self.assertRedirectsNoFollow(res, INDEX_URL)
|
qs = "?%s=%s" % (tg.param_name, tg.get_tab("log").get_id())
|
||||||
|
res = self.client.get(url + qs)
|
||||||
|
self.assertContains(res, "Unable to get log for")
|
||||||
|
|
||||||
def test_instance_vnc(self):
|
def test_instance_vnc(self):
|
||||||
server = self.servers.first()
|
server = self.servers.first()
|
||||||
|
@ -32,8 +32,9 @@ from django.utils.translation import ugettext as _
|
|||||||
from horizon import api
|
from horizon import api
|
||||||
from horizon import exceptions
|
from horizon import exceptions
|
||||||
from horizon import forms
|
from horizon import forms
|
||||||
from horizon import views
|
from horizon import tabs
|
||||||
from .forms import UpdateInstance
|
from .forms import UpdateInstance
|
||||||
|
from .tabs import InstanceDetailTabs
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
@ -42,18 +43,14 @@ LOG = logging.getLogger(__name__)
|
|||||||
def console(request, instance_id):
|
def console(request, instance_id):
|
||||||
try:
|
try:
|
||||||
# TODO(jakedahn): clean this up once the api supports tailing.
|
# TODO(jakedahn): clean this up once the api supports tailing.
|
||||||
length = request.GET.get('length', None)
|
data = api.server_console_output(request, instance_id)
|
||||||
console = api.server_console_output(request,
|
|
||||||
instance_id,
|
|
||||||
tail_length=length)
|
|
||||||
response = http.HttpResponse(mimetype='text/plain')
|
|
||||||
response.write(console)
|
|
||||||
response.flush()
|
|
||||||
return response
|
|
||||||
except:
|
except:
|
||||||
msg = _('Unable to get log for instance "%s".') % instance_id
|
data = _('Unable to get log for instance "%s".') % instance_id
|
||||||
redirect = reverse('horizon:nova:instances_and_volumes:index')
|
exceptions.handle(request, ignore=True)
|
||||||
exceptions.handle(request, msg, redirect=redirect)
|
response = http.HttpResponse(mimetype='text/plain')
|
||||||
|
response.write(data)
|
||||||
|
response.flush()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def vnc(request, instance_id):
|
def vnc(request, instance_id):
|
||||||
@ -90,47 +87,37 @@ class UpdateView(forms.ModalFormView):
|
|||||||
'name': getattr(self.object, 'name', '')}
|
'name': getattr(self.object, 'name', '')}
|
||||||
|
|
||||||
|
|
||||||
class DetailView(views.APIView):
|
class DetailView(tabs.TabView):
|
||||||
|
tab_group_class = InstanceDetailTabs
|
||||||
template_name = 'nova/instances_and_volumes/instances/detail.html'
|
template_name = 'nova/instances_and_volumes/instances/detail.html'
|
||||||
|
|
||||||
def get_data(self, request, context, *args, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
instance_id = kwargs['instance_id']
|
context = super(DetailView, self).get_context_data(**kwargs)
|
||||||
|
context["instance"] = self.get_data()
|
||||||
if "show" in request.GET:
|
|
||||||
show_tab = request.GET["show"]
|
|
||||||
else:
|
|
||||||
show_tab = "overview"
|
|
||||||
|
|
||||||
try:
|
|
||||||
instance = api.server_get(request, instance_id)
|
|
||||||
volumes = api.volume_instance_list(request, instance_id)
|
|
||||||
|
|
||||||
# Gather our flavors and images and correlate our instances to
|
|
||||||
# them. Exception handling happens in the parent class.
|
|
||||||
flavors = api.flavor_list(request)
|
|
||||||
full_flavors = SortedDict([(str(flavor.id), flavor) for \
|
|
||||||
flavor in flavors])
|
|
||||||
instance.full_flavor = full_flavors[instance.flavor["id"]]
|
|
||||||
|
|
||||||
context.update({'instance': instance, 'volumes': volumes})
|
|
||||||
except:
|
|
||||||
redirect = reverse('horizon:nova:instances_and_volumes:index')
|
|
||||||
exceptions.handle(request,
|
|
||||||
_('Unable to retrieve details for '
|
|
||||||
'instance "%s".') % instance_id,
|
|
||||||
redirect=redirect)
|
|
||||||
if show_tab == "vnc":
|
|
||||||
try:
|
|
||||||
console = api.server_vnc_console(request, instance_id)
|
|
||||||
vnc_url = "%s&title=%s(%s)" % (console.url,
|
|
||||||
getattr(instance, "name", ""),
|
|
||||||
instance_id)
|
|
||||||
context.update({'vnc_url': vnc_url})
|
|
||||||
except:
|
|
||||||
exceptions.handle(request,
|
|
||||||
_('Unable to get vnc console for '
|
|
||||||
'instance "%s".') % instance_id)
|
|
||||||
|
|
||||||
context.update({'show_tab': show_tab})
|
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
def get_data(self):
|
||||||
|
if not hasattr(self, "_instance"):
|
||||||
|
try:
|
||||||
|
instance_id = self.kwargs['instance_id']
|
||||||
|
instance = api.server_get(self.request, instance_id)
|
||||||
|
instance.volumes = api.volume_instance_list(self.request,
|
||||||
|
instance_id)
|
||||||
|
# Gather our flavors and images and correlate our instances to
|
||||||
|
# them. Exception handling happens in the parent class.
|
||||||
|
flavors = api.flavor_list(self.request)
|
||||||
|
full_flavors = SortedDict([(str(flavor.id), flavor) for \
|
||||||
|
flavor in flavors])
|
||||||
|
instance.full_flavor = full_flavors[instance.flavor["id"]]
|
||||||
|
except:
|
||||||
|
redirect = reverse('horizon:nova:instances_and_volumes:index')
|
||||||
|
exceptions.handle(self.request,
|
||||||
|
_('Unable to retrieve details for '
|
||||||
|
'instance "%s".') % instance_id,
|
||||||
|
redirect=redirect)
|
||||||
|
self._instance = instance
|
||||||
|
return self._instance
|
||||||
|
|
||||||
|
def get_tabs(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_data()
|
||||||
|
return self.tab_group_class(request, instance=instance, **kwargs)
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<div class="clearfix">
|
||||||
|
<h3 class="pull-left">Instance Console Log</h3>
|
||||||
|
<p class="pull-right">
|
||||||
|
{% url horizon:nova:instances_and_volumes:instances:console instance.id as console_url %}
|
||||||
|
<a class="btn btn-small" target="_blank" href="{{ console_url }}">{% trans "View Full Log" %}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<pre class="logs">{{ console_log }}</pre>
|
@ -0,0 +1,65 @@
|
|||||||
|
{% load i18n sizeformat %}
|
||||||
|
|
||||||
|
<h3>{% trans "Instance Overview" %}</h3>
|
||||||
|
|
||||||
|
<div class="status row-fluid">
|
||||||
|
<h4>{% trans "Status" %}</h4>
|
||||||
|
<hr class="header_rule">
|
||||||
|
<ul>
|
||||||
|
<li><strong>{% trans "Status" %}:</strong> {{ instance.status }}</li>
|
||||||
|
<li><strong>{% trans "Instance Name" %}:</strong> {{ instance.name }}</li>
|
||||||
|
<li><strong>{% trans "Instance ID" %}:</strong> {{ instance.id }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="specs row-fluid">
|
||||||
|
<h4>{% trans "Specs" %}</h4>
|
||||||
|
<hr class="header_rule">
|
||||||
|
<ul>
|
||||||
|
<li><strong>{% trans "RAM" %}:</strong> {{ instance.full_flavor.ram|mbformat }}</li>
|
||||||
|
<li><strong>{% trans "VCPUs" %}:</strong> {{ instance.full_flavor.vcpus }} {% trans "VCPU" %}</li>
|
||||||
|
<li><strong>{% trans "Disk" %}:</strong> {{ instance.full_flavor.disk }}{% trans "GB" %}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="addresses row-fluid">
|
||||||
|
<h4>{% trans "IP Addresses" %}</h4>
|
||||||
|
<hr class="header_rule">
|
||||||
|
<ul>
|
||||||
|
{% for network, ip_list in instance.addresses.items %}
|
||||||
|
<li><strong>{{ network|title }}:</strong>
|
||||||
|
{% for ip in ip_list %}
|
||||||
|
{% if not forloop.last %}{{ ip.addr}}, {% else %}{{ip.addr}}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta row-fluid">
|
||||||
|
<h4>{% trans "Meta" %}</h4>
|
||||||
|
<hr class="header_rule">
|
||||||
|
<ul>
|
||||||
|
<li><strong>{% trans "Key name" %}:</strong> {% if instance.key_name %}{{ instance.key_name }}{% else %}<em>None</em>{% endif %}</li>
|
||||||
|
<li><strong>{% trans "Image Name" %}:</strong> {{ instance.image_name }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="volumes row-fluid">
|
||||||
|
<h4>{% trans "Volumes" %}</h4>
|
||||||
|
<hr class="header_rule">
|
||||||
|
<ul>
|
||||||
|
{% if volumes %}
|
||||||
|
{% for volume in instance.volumes %}
|
||||||
|
<li>
|
||||||
|
<strong>{% trans "Volume" %}:</strong>
|
||||||
|
<a href="{% url horizon:nova:instances_and_volumes:volumes:detail volume.volumeId %}">
|
||||||
|
{{ volume.volumeId }} ({{ volume.device }})
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<li><em>{% trans "No volumes attached." %}</em></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
@ -0,0 +1,5 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<h3>{% trans "Instance VNC Console" %}</h3>
|
||||||
|
<p class='alert alert-info'>{% blocktrans %}If VNC console is not responding to keyboard input: click the grey status bar below.{% endblocktrans %}</p>
|
||||||
|
<iframe src="{{ vnc_url }}" width="720" height="430"></iframe>
|
@ -8,122 +8,30 @@
|
|||||||
{% endblock page_header %}
|
{% endblock page_header %}
|
||||||
|
|
||||||
{% block dash_main %}
|
{% block dash_main %}
|
||||||
<ul id="instance_tabs">
|
<div class="row-fluid">
|
||||||
<li{% if show_tab == "overview" %} class="active"{% endif %}><a class="overview" href="?show=overview">{% trans "Overview" %}</a></li>
|
<div class="span12">
|
||||||
<li{% if show_tab == "log" %} class="active"{% endif %}><a class="log" href="?show=log">{% trans "Log" %}</a></li>
|
{{ tab_group.render }}
|
||||||
<li{% if show_tab == "vnc" %} class="active"{% endif %}><a class="vnc" href="?show=vnc">{% trans "VNC" %}</a></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="dash_block">
|
|
||||||
{% if show_tab == "overview" %}
|
|
||||||
<div id="overview" class="tab_wrapper">
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<div class="status">
|
|
||||||
<h4>{% trans "Status" %}</h4>
|
|
||||||
<ul>
|
|
||||||
<li><span>{% trans "Status:" %}</span> {{ instance.status }}</li>
|
|
||||||
<li><span>{% trans "Instance Name:" %}</span> {{ instance.name }}</li>
|
|
||||||
<li><span>{% trans "Instance ID:" %}</span> {{ instance.id }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<div class="specs">
|
|
||||||
<h4>{% trans "Specs" %}</h4>
|
|
||||||
<ul>
|
|
||||||
<li><span>{% trans "RAM:" %}</span> {{ instance.full_flavor.ram|mbformat }}</li>
|
|
||||||
<li><span>{% trans "VCPUs:" %}</span> {{ instance.full_flavor.vcpus }} {% trans "VCPU" %}</li>
|
|
||||||
<li><span>{% trans "Disk:" %}</span> {{ instance.full_flavor.disk }}{% trans "GB" %}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<div class="addresses">
|
|
||||||
<h4>{% trans "IP Addresses" %}</h4>
|
|
||||||
<ul>
|
|
||||||
{% for network, ip_list in instance.addresses.items %}
|
|
||||||
<li><span>{{ network|title }}:</span>
|
|
||||||
{% for ip in ip_list %}
|
|
||||||
{% if not forloop.last %}{{ ip.addr}}, {% else %}{{ip.addr}}{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<div class="meta">
|
|
||||||
<h4>{% trans "Meta" %}</h4>
|
|
||||||
<ul>
|
|
||||||
<li><span>{% trans "Key name:" %}</span> {{ instance.key_name }}</li>
|
|
||||||
{% comment %} Security Groups aren't sent back from Nova anymore...
|
|
||||||
<li><span>{% trans "Security Group(s):" %}</span> {% for group in instance.attrs.security_groups %}{{group}}, {% endfor %}</li>
|
|
||||||
{% endcomment %}
|
|
||||||
<li><span>{% trans "Image Name:" %}</span> {{ instance.image_name }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
<h4>{% trans "Volumes" %}</h4>
|
|
||||||
<ul>
|
|
||||||
{% if volumes %}
|
|
||||||
{% for volume in volumes %}
|
|
||||||
<li>
|
|
||||||
<span>{% trans "Volume:" %}</span>
|
|
||||||
<a href="{% url horizon:nova:instances_and_volumes:volumes:detail volume.volumeId %}">
|
|
||||||
{{ volume.volumeId }} ({{ volume.device }})
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<li><span>{% trans "None Attached" %}</span></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if show_tab == "log" %}
|
|
||||||
<div id="log" class="tab_wrapper">
|
|
||||||
<a class="view_full" target="_blank" href="{% url horizon:nova:instances_and_volumes:instances:console instance.id %}">{% trans "View Full Log" %}</a>
|
|
||||||
<pre class="logs"></pre>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if show_tab == "vnc" %}
|
|
||||||
<div id="vnc" class="tab_wrapper">
|
|
||||||
<p class='alert alert-info'>If VNC console is not responding to keyboard input: click the grey status bar below.</p>
|
|
||||||
<iframe src="{{vnc_url}}" width="720" height="430"></iframe>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js %}
|
{% block js %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
{# FIXME: This JavaScript should live with the rest of the JS #}
|
{# FIXME: This JavaScript should live with the rest of the JS #}
|
||||||
{% if show_tab == "log" %}
|
|
||||||
<script type="text/javascript" charset="utf-8">
|
<script type="text/javascript" charset="utf-8">
|
||||||
$(function() {
|
$(function() {
|
||||||
function getLog() {
|
function getLog() {
|
||||||
$.get("{% url horizon:nova:instances_and_volumes:instances:console instance.id %}?length=25", function(data) {
|
if ($("#instance_details__log .logs").length) {
|
||||||
$("#log .logs").html(data);
|
$.get("{% url horizon:nova:instances_and_volumes:instances:console instance.id %}?length=25", function(data) {
|
||||||
});
|
$("#instance_details__log .logs").html(data);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
getLog();
|
getLog();
|
||||||
|
|
||||||
setInterval(function() {
|
setInterval(function() {
|
||||||
getLog();
|
getLog();
|
||||||
}, 3000);
|
}, 10000); // This value has to stay under Nova's API rate limiting.
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
15
horizon/horizon/static/horizon/js/tabs.js
Normal file
15
horizon/horizon/static/horizon/js/tabs.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
horizon.tabs = {};
|
||||||
|
|
||||||
|
horizon.tabs.load_tab = function (evt) {
|
||||||
|
var $this = $(this),
|
||||||
|
tab_id = $this.attr('data-target'),
|
||||||
|
tab_pane = $(tab_id);
|
||||||
|
tab_pane.append("<i class='icon icon-updating ajax-updating'></i> <span>loading...</span>");
|
||||||
|
tab_pane.load("?tab=" + tab_id.replace('#', ''));
|
||||||
|
$this.attr("data-loaded", "true");
|
||||||
|
evt.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
horizon.addInitFunction(function () {
|
||||||
|
$(document).on("click", ".ajax-tabs a[data-loaded='false']", horizon.tabs.load_tab);
|
||||||
|
});
|
@ -14,7 +14,6 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
import copy
|
|
||||||
import logging
|
import logging
|
||||||
import new
|
import new
|
||||||
from urlparse import urlparse
|
from urlparse import urlparse
|
||||||
@ -23,7 +22,6 @@ from urlparse import parse_qs
|
|||||||
from django import http
|
from django import http
|
||||||
from django import shortcuts
|
from django import shortcuts
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.forms.util import flatatt
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core import urlresolvers
|
from django.core import urlresolvers
|
||||||
from django.utils.functional import Promise
|
from django.utils.functional import Promise
|
||||||
@ -31,6 +29,7 @@ from django.utils.http import urlencode
|
|||||||
from django.utils.translation import string_concat, ugettext as _
|
from django.utils.translation import string_concat, ugettext as _
|
||||||
|
|
||||||
from horizon import exceptions
|
from horizon import exceptions
|
||||||
|
from horizon.utils import html
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
@ -39,17 +38,13 @@ LOG = logging.getLogger(__name__)
|
|||||||
ACTION_CSS_CLASSES = ("btn", "btn-small")
|
ACTION_CSS_CLASSES = ("btn", "btn-small")
|
||||||
|
|
||||||
|
|
||||||
class BaseAction(object):
|
class BaseAction(html.HTMLElement):
|
||||||
""" Common base class for all ``Action`` classes. """
|
""" Common base class for all ``Action`` classes. """
|
||||||
table = None
|
table = None
|
||||||
handles_multiple = False
|
handles_multiple = False
|
||||||
requires_input = False
|
requires_input = False
|
||||||
preempt = False
|
preempt = False
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.attrs = getattr(self, "attrs", {})
|
|
||||||
self.classes = getattr(self, "classes", [])
|
|
||||||
|
|
||||||
def allowed(self, request, datum):
|
def allowed(self, request, datum):
|
||||||
""" Determine whether this action is allowed for the current request.
|
""" Determine whether this action is allowed for the current request.
|
||||||
|
|
||||||
@ -74,22 +69,12 @@ class BaseAction(object):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@property
|
def get_default_classes(self):
|
||||||
def attr_string(self):
|
|
||||||
"""
|
"""
|
||||||
Returns a flattened string of HTML attributes based on the
|
Returns a list of the default classes for the tab. Defaults to
|
||||||
``attrs`` dict provided to the class.
|
``["btn", "btn-small"]``.
|
||||||
"""
|
"""
|
||||||
final_attrs = copy.copy(self.attrs)
|
return getattr(settings, "ACTION_CSS_CLASSES", ACTION_CSS_CLASSES)
|
||||||
# Handle css class concatenation
|
|
||||||
default = " ".join(getattr(settings,
|
|
||||||
"ACTION_CSS_CLASSES",
|
|
||||||
ACTION_CSS_CLASSES))
|
|
||||||
defined = self.attrs.get('class', '')
|
|
||||||
additional = " ".join(self.classes)
|
|
||||||
final_classes = " ".join((defined, default, additional)).strip()
|
|
||||||
final_attrs.update({'class': final_classes})
|
|
||||||
return flatatt(final_attrs)
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<%s: %s>" % (self.__class__.__name__, self.name)
|
return "<%s: %s>" % (self.__class__.__name__, self.name)
|
||||||
|
18
horizon/horizon/tabs/__init__.py
Normal file
18
horizon/horizon/tabs/__init__.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# Copyright 2012 Nebula, Inc.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from .base import TabGroup, Tab
|
||||||
|
from .views import TabView
|
336
horizon/horizon/tabs/base.py
Normal file
336
horizon/horizon/tabs/base.py
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# Copyright 2012 Nebula, Inc.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from django.template import TemplateSyntaxError
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.datastructures import SortedDict
|
||||||
|
|
||||||
|
from horizon.utils import html
|
||||||
|
|
||||||
|
SEPARATOR = "__"
|
||||||
|
CSS_TAB_GROUP_CLASSES = ["nav", "nav-tabs", "ajax-tabs"]
|
||||||
|
CSS_ACTIVE_TAB_CLASSES = ["active"]
|
||||||
|
CSS_DISABLED_TAB_CLASSES = ["disabled"]
|
||||||
|
|
||||||
|
|
||||||
|
class TabGroup(html.HTMLElement):
|
||||||
|
"""
|
||||||
|
A container class which knows how to manage and render
|
||||||
|
:class:`~horizon.tabs.Tab` objects.
|
||||||
|
|
||||||
|
.. attribute:: slug
|
||||||
|
|
||||||
|
The URL slug and pseudo-unique identifier for this tab group.
|
||||||
|
|
||||||
|
.. attribute:: template_name
|
||||||
|
|
||||||
|
The name of the template which will be used to render this tab group.
|
||||||
|
Default: ``"horizon/common/_tab_group.html"``
|
||||||
|
|
||||||
|
.. attribute:: param_name
|
||||||
|
|
||||||
|
The name of the GET request parameter which will be used when
|
||||||
|
requesting specific tab data. Default: ``tab``.
|
||||||
|
|
||||||
|
.. attribute:: classes
|
||||||
|
|
||||||
|
A list of CSS classes which should be displayed on this tab group.
|
||||||
|
|
||||||
|
.. attribute:: attrs
|
||||||
|
|
||||||
|
A dictionary of HTML attributes which should be rendered into the
|
||||||
|
markup for this tab group.
|
||||||
|
|
||||||
|
.. attribute:: selected
|
||||||
|
|
||||||
|
Read-only property which is set to the instance of the
|
||||||
|
currently-selected tab if there is one, otherwise ``None``.
|
||||||
|
|
||||||
|
.. attribute:: active
|
||||||
|
|
||||||
|
Read-only property which is set to the value of the current active tab.
|
||||||
|
This may not be the same as the value of ``selected`` if no
|
||||||
|
specific tab was requested via the ``GET`` parameter.
|
||||||
|
"""
|
||||||
|
slug = None
|
||||||
|
template_name = "horizon/common/_tab_group.html"
|
||||||
|
param_name = 'tab'
|
||||||
|
_selected = None
|
||||||
|
_active = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def selected(self):
|
||||||
|
return self._selected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self):
|
||||||
|
return self._active
|
||||||
|
|
||||||
|
def __init__(self, request, **kwargs):
|
||||||
|
super(TabGroup, self).__init__()
|
||||||
|
if not hasattr(self, "tabs"):
|
||||||
|
raise NotImplementedError('%s must declare a "tabs" attribute.'
|
||||||
|
% self.__class__)
|
||||||
|
self.request = request
|
||||||
|
self.kwargs = kwargs
|
||||||
|
tab_instances = []
|
||||||
|
for tab in self.tabs:
|
||||||
|
tab_instances.append((tab.slug, tab(self, request)))
|
||||||
|
self._tabs = SortedDict(tab_instances)
|
||||||
|
if not self._set_active_tab():
|
||||||
|
self.tabs_not_available()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "<%s: %s>" % (self.__class__.__name__, self.slug)
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
"""
|
||||||
|
Returns the id for this tab group. Defaults to the value of the tab
|
||||||
|
group's :attr:`horizon.tabs.Tab.slug`.
|
||||||
|
"""
|
||||||
|
return self.slug
|
||||||
|
|
||||||
|
def get_default_classes(self):
|
||||||
|
"""
|
||||||
|
Returns a list of the default classes for the tab group. Defaults to
|
||||||
|
``["nav", "nav-tabs", "ajax-tabs"]``.
|
||||||
|
"""
|
||||||
|
default_classes = super(TabGroup, self).get_default_classes()
|
||||||
|
default_classes.extend(CSS_TAB_GROUP_CLASSES)
|
||||||
|
return default_classes
|
||||||
|
|
||||||
|
def tabs_not_available(self):
|
||||||
|
"""
|
||||||
|
In the event that no tabs are either allowed or enabled, this method
|
||||||
|
is the fallback handler. By default it's a no-op, but it exists
|
||||||
|
to make redirecting or raising exceptions possible for subclasses.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _set_active_tab(self):
|
||||||
|
marked_active = None
|
||||||
|
|
||||||
|
# See if we have a selected tab via the GET parameter.
|
||||||
|
tab = self.get_selected_tab()
|
||||||
|
if tab:
|
||||||
|
tab._active = True
|
||||||
|
self._active = tab
|
||||||
|
marked_active = tab
|
||||||
|
|
||||||
|
# Iterate through to mark them all accordingly.
|
||||||
|
for tab in self._tabs.values():
|
||||||
|
if tab._allowed and tab._enabled and not marked_active:
|
||||||
|
tab._active = True
|
||||||
|
self._active = tab
|
||||||
|
marked_active = True
|
||||||
|
elif tab == marked_active:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
tab._active = False
|
||||||
|
|
||||||
|
return marked_active
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
""" Renders the HTML output for this tab group. """
|
||||||
|
return render_to_string(self.template_name, {"tab_group": self})
|
||||||
|
|
||||||
|
def get_tabs(self):
|
||||||
|
""" Returns a list of the allowed tabs for this tab group. """
|
||||||
|
return filter(lambda tab: tab._allowed, self._tabs.values())
|
||||||
|
|
||||||
|
def get_tab(self, tab_name, allow_disabled=False):
|
||||||
|
""" Returns a specific tab from this tab group.
|
||||||
|
|
||||||
|
If the tab is not allowed or not enabled this method returns ``None``.
|
||||||
|
|
||||||
|
If the tab is disabled but you wish to return it anyway, you can pass
|
||||||
|
``True`` to the allow_disabled argument.
|
||||||
|
"""
|
||||||
|
tab = self._tabs.get(tab_name, None)
|
||||||
|
if tab and tab._allowed and (tab._enabled or allow_disabled):
|
||||||
|
return tab
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_selected_tab(self):
|
||||||
|
""" Returns the tab specific by the GET request parameter.
|
||||||
|
|
||||||
|
In the event that there is no GET request parameter, the value
|
||||||
|
of the query parameter is invalid, or the tab is not allowed/enabled,
|
||||||
|
the return value of this function is None.
|
||||||
|
"""
|
||||||
|
selected = self.request.GET.get(self.param_name, None)
|
||||||
|
if selected:
|
||||||
|
tab_group, tab_name = selected.split(SEPARATOR)
|
||||||
|
if tab_group == self.get_id():
|
||||||
|
self._selected = self.get_tab(tab_name)
|
||||||
|
return self._selected
|
||||||
|
|
||||||
|
|
||||||
|
class Tab(html.HTMLElement):
|
||||||
|
"""
|
||||||
|
A reusable interface for constructing a tab within a
|
||||||
|
:class:`~horizon.tabs.TabGroup`.
|
||||||
|
|
||||||
|
.. attribute:: name
|
||||||
|
|
||||||
|
The display name for the tab which will be rendered as the text for
|
||||||
|
the tab element in the HTML. Required.
|
||||||
|
|
||||||
|
.. attribute:: slug
|
||||||
|
|
||||||
|
The URL slug and id attribute for the tab. This should be unique for
|
||||||
|
a given tab group. Required.
|
||||||
|
|
||||||
|
.. attribute:: preload
|
||||||
|
|
||||||
|
Determines whether the contents of the tab should be rendered into
|
||||||
|
the page's HTML when the tab group is rendered, or whether it should
|
||||||
|
be loaded dynamically when the tab is selected. Default: ``True``.
|
||||||
|
|
||||||
|
.. attribute:: classes
|
||||||
|
|
||||||
|
A list of CSS classes which should be displayed on this tab.
|
||||||
|
|
||||||
|
.. attribute:: attrs
|
||||||
|
|
||||||
|
A dictionary of HTML attributes which should be rendered into the
|
||||||
|
markup for this tab.
|
||||||
|
|
||||||
|
.. attribute:: load
|
||||||
|
|
||||||
|
Read-only access to determine whether or not this tab's data should
|
||||||
|
be loaded immediately.
|
||||||
|
"""
|
||||||
|
name = None
|
||||||
|
slug = None
|
||||||
|
preload = True
|
||||||
|
_active = None
|
||||||
|
|
||||||
|
def __init__(self, tab_group, request):
|
||||||
|
super(Tab, self).__init__()
|
||||||
|
# Priority: constructor, class-defined, fallback
|
||||||
|
if not self.name:
|
||||||
|
raise ValueError("%s must have a name." % self.__class__.__name__)
|
||||||
|
self.name = unicode(self.name) # Force unicode.
|
||||||
|
if not self.slug:
|
||||||
|
raise ValueError("%s must have a slug." % self.__class__.__name__)
|
||||||
|
self.request = request
|
||||||
|
self.tab_group = tab_group
|
||||||
|
self._allowed = self.allowed(request)
|
||||||
|
self._enabled = self.enabled(request)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "<%s: %s>" % (self.__class__.__name__, self.slug)
|
||||||
|
|
||||||
|
def is_active(self):
|
||||||
|
""" Method to access whether or not this tab is the active tab. """
|
||||||
|
if self._active is None:
|
||||||
|
self.tab_group._set_active_tab()
|
||||||
|
return self._active
|
||||||
|
|
||||||
|
@property
|
||||||
|
def load(self):
|
||||||
|
load_preloaded = self.preload or self.is_active()
|
||||||
|
return load_preloaded and self._allowed and self._enabled
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
"""
|
||||||
|
Renders the tab to HTML using the :meth:`~horizon.tabs.Tab.get_data`
|
||||||
|
method and the :meth:`~horizon.tabs.Tab.get_template_name` method.
|
||||||
|
|
||||||
|
If :attr:`~horizon.tabs.Tab.preload` is ``False`` and ``force_load``
|
||||||
|
is not ``True``, or
|
||||||
|
either :meth:`~horizon.tabs.Tab.allowed` or
|
||||||
|
:meth:`~horizon.tabs.Tab.enabled` returns ``False`` this method will
|
||||||
|
return an empty string.
|
||||||
|
"""
|
||||||
|
if not self.load:
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
context = self.get_context_data(self.request)
|
||||||
|
except Exception as exc:
|
||||||
|
raise TemplateSyntaxError(exc)
|
||||||
|
return render_to_string(self.get_template_name(self.request), context)
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
"""
|
||||||
|
Returns the id for this tab. Defaults to
|
||||||
|
``"{{ tab_group.slug }}__{{ tab.slug }}"``.
|
||||||
|
"""
|
||||||
|
return SEPARATOR.join([self.tab_group.slug, self.slug])
|
||||||
|
|
||||||
|
def get_default_classes(self):
|
||||||
|
"""
|
||||||
|
Returns a list of the default classes for the tab. Defaults to
|
||||||
|
and empty list (``[]``), however additional classes may be added
|
||||||
|
depending on the state of the tab as follows:
|
||||||
|
|
||||||
|
If the tab is the active tab for the tab group, in which
|
||||||
|
the class ``"active"`` will be added.
|
||||||
|
|
||||||
|
If the tab is not enabled, the classes the class ``"disabled"``
|
||||||
|
will be added.
|
||||||
|
"""
|
||||||
|
default_classes = super(Tab, self).get_default_classes()
|
||||||
|
if self.is_active():
|
||||||
|
default_classes.extend(CSS_ACTIVE_TAB_CLASSES)
|
||||||
|
if not self._enabled:
|
||||||
|
default_classes.extend(CSS_DISABLED_TAB_CLASSES)
|
||||||
|
return default_classes
|
||||||
|
|
||||||
|
def get_template_name(self, request):
|
||||||
|
"""
|
||||||
|
Returns the name of the template to be used for rendering this tab.
|
||||||
|
|
||||||
|
By default it returns the value of the ``template_name`` attribute
|
||||||
|
on the ``Tab`` class.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "template_name"):
|
||||||
|
raise AttributeError("%s must have a template_name attribute or "
|
||||||
|
"override the get_template_name method."
|
||||||
|
% self.__class__.__name__)
|
||||||
|
return self.template_name
|
||||||
|
|
||||||
|
def get_context_data(self, request):
|
||||||
|
"""
|
||||||
|
This method should return a dictionary of context data used to render
|
||||||
|
the tab. Required.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("%s needs to define a get_context_data "
|
||||||
|
"method." % self.__class__.__name__)
|
||||||
|
|
||||||
|
def enabled(self, request):
|
||||||
|
"""
|
||||||
|
Determines whether or not the tab should be accessible
|
||||||
|
(e.g. be rendered into the HTML on load and respond to a click event).
|
||||||
|
|
||||||
|
If a tab returns ``False`` from ``enabled`` it will ignore the value
|
||||||
|
of ``preload`` and only render the HTML of the tab after being clicked.
|
||||||
|
|
||||||
|
The default behavior is to return ``True`` for all cases.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def allowed(self, request):
|
||||||
|
"""
|
||||||
|
Determines whether or not the tab is displayed.
|
||||||
|
|
||||||
|
Tab instances can override this method to specify conditions under
|
||||||
|
which this tab should not be shown at all by returning ``False``.
|
||||||
|
|
||||||
|
The default behavior is to return ``True`` for all cases.
|
||||||
|
"""
|
||||||
|
return True
|
42
horizon/horizon/tabs/views.py
Normal file
42
horizon/horizon/tabs/views.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from django import http
|
||||||
|
from django.views import generic
|
||||||
|
|
||||||
|
from horizon import exceptions
|
||||||
|
|
||||||
|
|
||||||
|
class TabView(generic.TemplateView):
|
||||||
|
"""
|
||||||
|
A generic class-based view for displaying a :class:`horizon.tabs.TabGroup`.
|
||||||
|
|
||||||
|
This view handles selecting specific tabs and deals with AJAX requests
|
||||||
|
gracefully.
|
||||||
|
|
||||||
|
.. attribute:: tab_group_class
|
||||||
|
|
||||||
|
The only required attribute for ``TabView``. It should be a class which
|
||||||
|
inherits from :class:`horizon.tabs.TabGroup`.
|
||||||
|
"""
|
||||||
|
tab_group_class = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if not self.tab_group_class:
|
||||||
|
raise AttributeError("You must set the tab_group attribute on %s."
|
||||||
|
% self.__class__.__name__)
|
||||||
|
|
||||||
|
def get_tabs(self, request, *args, **kwargs):
|
||||||
|
return self.tab_group_class(request, **kwargs)
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
context = self.get_context_data(**kwargs)
|
||||||
|
try:
|
||||||
|
tab_group = self.get_tabs(request, *args, **kwargs)
|
||||||
|
context["tab_group"] = tab_group
|
||||||
|
except:
|
||||||
|
exceptions.handle(request)
|
||||||
|
|
||||||
|
if request.is_ajax():
|
||||||
|
if tab_group.selected:
|
||||||
|
return http.HttpResponse(tab_group.selected.render())
|
||||||
|
else:
|
||||||
|
return http.HttpResponse(tab_group.render())
|
||||||
|
return self.render_to_response(context)
|
23
horizon/horizon/templates/horizon/common/_tab_group.html
Normal file
23
horizon/horizon/templates/horizon/common/_tab_group.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{% with tab_group.get_tabs as tabs %}
|
||||||
|
{% if tabs %}
|
||||||
|
|
||||||
|
{# Tab Navigation #}
|
||||||
|
<ul id="{{ tab_group.get_id }}" {{ tab_group.attr_string|safe }}>
|
||||||
|
{% for tab in tabs %}
|
||||||
|
<li {{ tab.attr_string|safe }}>
|
||||||
|
<a href="?{{ tab_group.param_name}}={{ tab.get_id }}" data-toggle="tab" data-target="#{{ tab.get_id }}" data-loaded='{{ tab.load|yesno:"true,false" }}'>{{ tab.name }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{# Tab Content #}
|
||||||
|
<div class="tab-content">
|
||||||
|
{% for tab in tabs %}
|
||||||
|
<div id="{{ tab.get_id }}" class="tab-pane{% if tab.is_active %} active{% endif %}">
|
||||||
|
{{ tab.render }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
192
horizon/horizon/tests/tabs_tests.py
Normal file
192
horizon/horizon/tests/tabs_tests.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||||
|
|
||||||
|
# Copyright 2012 Nebula, Inc.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
from django import http
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from horizon import tabs as horizon_tabs
|
||||||
|
from horizon import test
|
||||||
|
|
||||||
|
|
||||||
|
class BaseTestTab(horizon_tabs.Tab):
|
||||||
|
def get_context_data(self, request):
|
||||||
|
return {"tab": self}
|
||||||
|
|
||||||
|
|
||||||
|
class TabOne(BaseTestTab):
|
||||||
|
slug = "tab_one"
|
||||||
|
name = _("Tab One")
|
||||||
|
template_name = "_tab.html"
|
||||||
|
|
||||||
|
|
||||||
|
class TabDelayed(BaseTestTab):
|
||||||
|
slug = "tab_delayed"
|
||||||
|
name = _("Delayed Tab")
|
||||||
|
template_name = "_tab.html"
|
||||||
|
preload = False
|
||||||
|
|
||||||
|
|
||||||
|
class TabDisabled(BaseTestTab):
|
||||||
|
slug = "tab_disabled"
|
||||||
|
name = _("Disabled Tab")
|
||||||
|
template_name = "_tab.html"
|
||||||
|
|
||||||
|
def enabled(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class TabDisallowed(BaseTestTab):
|
||||||
|
slug = "tab_disallowed"
|
||||||
|
name = _("Disallowed Tab")
|
||||||
|
template_name = "_tab.html"
|
||||||
|
|
||||||
|
def allowed(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Group(horizon_tabs.TabGroup):
|
||||||
|
slug = "tab_group"
|
||||||
|
tabs = (TabOne, TabDelayed, TabDisabled, TabDisallowed)
|
||||||
|
|
||||||
|
def tabs_not_available(self):
|
||||||
|
self._assert_tabs_not_available = True
|
||||||
|
|
||||||
|
|
||||||
|
class TabTests(test.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super(TabTests, self).setUp()
|
||||||
|
|
||||||
|
def test_tab_group_basics(self):
|
||||||
|
tg = Group(self.request)
|
||||||
|
|
||||||
|
# Test tab instantiation/attachement to tab group, and get_tabs method
|
||||||
|
tabs = tg.get_tabs()
|
||||||
|
# "tab_disallowed" should NOT be in this list.
|
||||||
|
self.assertQuerysetEqual(tabs, ['<TabOne: tab_one>',
|
||||||
|
'<TabDelayed: tab_delayed>',
|
||||||
|
'<TabDisabled: tab_disabled>'])
|
||||||
|
# Test get_id
|
||||||
|
self.assertEqual(tg.get_id(), "tab_group")
|
||||||
|
# get_default_classes
|
||||||
|
self.assertEqual(tg.get_default_classes(),
|
||||||
|
horizon_tabs.base.CSS_TAB_GROUP_CLASSES)
|
||||||
|
# Test get_tab
|
||||||
|
self.assertEqual(tg.get_tab("tab_one").slug, "tab_one")
|
||||||
|
|
||||||
|
# Test selected is None w/o GET input
|
||||||
|
self.assertEqual(tg.selected, None)
|
||||||
|
|
||||||
|
# Test get_selected_tab is None w/o GET input
|
||||||
|
self.assertEqual(tg.get_selected_tab(), None)
|
||||||
|
|
||||||
|
def test_tab_group_active_tab(self):
|
||||||
|
tg = Group(self.request)
|
||||||
|
|
||||||
|
# active tab w/o selected
|
||||||
|
self.assertEqual(tg.active, tg.get_tabs()[0])
|
||||||
|
|
||||||
|
# active tab w/ selected
|
||||||
|
self.request.GET['tab'] = "tab_group__tab_delayed"
|
||||||
|
tg = Group(self.request)
|
||||||
|
self.assertEqual(tg.active, tg.get_tab('tab_delayed'))
|
||||||
|
|
||||||
|
# active tab w/ invalid selected
|
||||||
|
self.request.GET['tab'] = "tab_group__tab_invalid"
|
||||||
|
tg = Group(self.request)
|
||||||
|
self.assertEqual(tg.active, tg.get_tabs()[0])
|
||||||
|
|
||||||
|
# active tab w/ disallowed selected
|
||||||
|
self.request.GET['tab'] = "tab_group__tab_disallowed"
|
||||||
|
tg = Group(self.request)
|
||||||
|
self.assertEqual(tg.active, tg.get_tabs()[0])
|
||||||
|
|
||||||
|
# active tab w/ disabled selected
|
||||||
|
self.request.GET['tab'] = "tab_group__tab_disabled"
|
||||||
|
tg = Group(self.request)
|
||||||
|
self.assertEqual(tg.active, tg.get_tabs()[0])
|
||||||
|
|
||||||
|
def test_tab_basics(self):
|
||||||
|
tg = Group(self.request)
|
||||||
|
tab_one = tg.get_tab("tab_one")
|
||||||
|
tab_delayed = tg.get_tab("tab_delayed")
|
||||||
|
tab_disabled = tg.get_tab("tab_disabled", allow_disabled=True)
|
||||||
|
|
||||||
|
# Disallowed tab isn't even returned
|
||||||
|
tab_disallowed = tg.get_tab("tab_disallowed")
|
||||||
|
self.assertEqual(tab_disallowed, None)
|
||||||
|
|
||||||
|
# get_id
|
||||||
|
self.assertEqual(tab_one.get_id(), "tab_group__tab_one")
|
||||||
|
|
||||||
|
# get_default_classes
|
||||||
|
self.assertEqual(tab_one.get_default_classes(),
|
||||||
|
horizon_tabs.base.CSS_ACTIVE_TAB_CLASSES)
|
||||||
|
self.assertEqual(tab_disabled.get_default_classes(),
|
||||||
|
horizon_tabs.base.CSS_DISABLED_TAB_CLASSES)
|
||||||
|
|
||||||
|
# load, allowed, enabled
|
||||||
|
self.assertTrue(tab_one.load)
|
||||||
|
self.assertFalse(tab_delayed.load)
|
||||||
|
self.assertFalse(tab_disabled.load)
|
||||||
|
self.request.GET['tab'] = tab_delayed.get_id()
|
||||||
|
tg = Group(self.request)
|
||||||
|
tab_delayed = tg.get_tab("tab_delayed")
|
||||||
|
self.assertTrue(tab_delayed.load)
|
||||||
|
|
||||||
|
# is_active
|
||||||
|
self.request.GET['tab'] = ""
|
||||||
|
tg = Group(self.request)
|
||||||
|
tab_one = tg.get_tab("tab_one")
|
||||||
|
tab_delayed = tg.get_tab("tab_delayed")
|
||||||
|
self.assertTrue(tab_one.is_active())
|
||||||
|
self.assertFalse(tab_delayed.is_active())
|
||||||
|
|
||||||
|
self.request.GET['tab'] = tab_delayed.get_id()
|
||||||
|
tg = Group(self.request)
|
||||||
|
tab_one = tg.get_tab("tab_one")
|
||||||
|
tab_delayed = tg.get_tab("tab_delayed")
|
||||||
|
self.assertFalse(tab_one.is_active())
|
||||||
|
self.assertTrue(tab_delayed.is_active())
|
||||||
|
|
||||||
|
def test_rendering(self):
|
||||||
|
tg = Group(self.request)
|
||||||
|
tab_one = tg.get_tab("tab_one")
|
||||||
|
tab_delayed = tg.get_tab("tab_delayed")
|
||||||
|
tab_disabled = tg.get_tab("tab_disabled", allow_disabled=True)
|
||||||
|
|
||||||
|
# tab group
|
||||||
|
output = tg.render()
|
||||||
|
res = http.HttpResponse(output.strip())
|
||||||
|
self.assertContains(res, "<li", 3)
|
||||||
|
|
||||||
|
# tab
|
||||||
|
output = tab_one.render()
|
||||||
|
self.assertEqual(output.strip(), tab_one.name)
|
||||||
|
|
||||||
|
# disabled tab
|
||||||
|
output = tab_disabled.render()
|
||||||
|
self.assertEqual(output.strip(), "")
|
||||||
|
|
||||||
|
# preload false
|
||||||
|
output = tab_delayed.render()
|
||||||
|
self.assertEqual(output.strip(), "")
|
||||||
|
|
||||||
|
# preload false w/ active
|
||||||
|
self.request.GET['tab'] = tab_delayed.get_id()
|
||||||
|
tg = Group(self.request)
|
||||||
|
tab_delayed = tg.get_tab("tab_delayed")
|
||||||
|
output = tab_delayed.render()
|
||||||
|
self.assertEqual(output.strip(), tab_delayed.name)
|
1
horizon/horizon/tests/templates/_tab.html
Normal file
1
horizon/horizon/tests/templates/_tab.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
{{ tab.name }}
|
32
horizon/horizon/utils/html.py
Normal file
32
horizon/horizon/utils/html.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import copy
|
||||||
|
|
||||||
|
from django.forms.util import flatatt
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLElement(object):
|
||||||
|
""" A generic base class that gracefully handles html-style attributes. """
|
||||||
|
def __init__(self):
|
||||||
|
self.attrs = getattr(self, "attrs", {})
|
||||||
|
self.classes = getattr(self, "classes", [])
|
||||||
|
|
||||||
|
def get_default_classes(self):
|
||||||
|
"""
|
||||||
|
Returns a list of default classes which should be combined with any
|
||||||
|
other declared classes.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attr_string(self):
|
||||||
|
"""
|
||||||
|
Returns a flattened string of HTML attributes based on the
|
||||||
|
``attrs`` dict provided to the class.
|
||||||
|
"""
|
||||||
|
final_attrs = copy.copy(self.attrs)
|
||||||
|
# Handle css class concatenation
|
||||||
|
default = " ".join(self.get_default_classes())
|
||||||
|
defined = self.attrs.get('class', '')
|
||||||
|
additional = " ".join(getattr(self, "classes", []))
|
||||||
|
final_classes = " ".join((defined, default, additional)).strip()
|
||||||
|
final_attrs.update({'class': final_classes})
|
||||||
|
return flatatt(final_attrs)
|
@ -29,7 +29,7 @@ class APIView(generic.TemplateView):
|
|||||||
the :func:`horizon.exceptions.handle` error handler if not otherwise
|
the :func:`horizon.exceptions.handle` error handler if not otherwise
|
||||||
caught.
|
caught.
|
||||||
"""
|
"""
|
||||||
def get_data(request, context, *args, **kwargs):
|
def get_data(self, request, context, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
This method should handle any necessary API calls, update the
|
This method should handle any necessary API calls, update the
|
||||||
context object, and return the context object at the end.
|
context object, and return the context object at the end.
|
||||||
|
@ -775,9 +775,14 @@ tr.terminated {
|
|||||||
margin-bottom: -5px;
|
margin-bottom: -5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.view_full {
|
#main_content .nav-tabs {
|
||||||
float: right;
|
margin-bottom: 0;
|
||||||
margin: 10px 10px 0 0;
|
}
|
||||||
|
|
||||||
|
#main_content .tab-content {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-top: 0 none;
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab_wrapper {
|
.tab_wrapper {
|
||||||
@ -885,3 +890,15 @@ form .error {
|
|||||||
height: 15px;
|
height: 15px;
|
||||||
margin: -8px 0 8px;
|
margin: -8px 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#main_content .row-fluid {
|
||||||
|
margin: 10px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main_content .row-fluid:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header_rule {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
{% comment %} Horizon-specific JS {% endcomment %}
|
{% comment %} Horizon-specific JS {% endcomment %}
|
||||||
<script src='{{ STATIC_URL }}horizon/js/horizon.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}horizon/js/horizon.js' type='text/javascript' charset='utf-8'></script>
|
||||||
|
<script src='{{ STATIC_URL }}horizon/js/tabs.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}dashboard/js/plugins.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}dashboard/js/plugins.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}dashboard/js/tables.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}dashboard/js/tables.js' type='text/javascript' charset='utf-8'></script>
|
||||||
<script src='{{ STATIC_URL }}dashboard/js/modals.js' type='text/javascript' charset='utf-8'></script>
|
<script src='{{ STATIC_URL }}dashboard/js/modals.js' type='text/javascript' charset='utf-8'></script>
|
||||||
|
Loading…
Reference in New Issue
Block a user