From a4e583cffbd2dbe2e431c306c389a0b0815073e1 Mon Sep 17 00:00:00 2001 From: Tihomir Trifonov Date: Wed, 22 May 2013 11:02:04 +0300 Subject: [PATCH] Improvements in csv export for usage data Added a csv writer using the 'csv' library to format properly exported data - escaping, encoding etc. Added a HttpResponse-based class to handle csv generation Added translation for the CSV columns and template. Improved consistency for exported data - now passing project name instead of project id for csv export. Also added both project name/id in the header of a project usage export. Fix bug #1158383 Renamed few occurencies of 'tenant' to project. Also added a new 'project' in nova_data.py, which required some refactoring of few tests, that didn't consider the current project for project-based calls. Note: I've added a StreamingHttpResponse example, which is introduced in Django 1.5+ and being advised in the ticket. However, my opinion is that at the moment we don't need this - it is too complicated for the current usage. Change-Id: Ic00626b273921fa5c6c89704b3a9e08b252aaeb0 --- .../dashboards/admin/instances/tests.py | 7 +- .../overview/templates/overview/usage.csv | 15 +- .../dashboards/admin/overview/tests.py | 21 +-- .../dashboards/admin/overview/views.py | 33 +++- .../dashboards/admin/projects/urls.py | 4 +- .../dashboards/admin/projects/views.py | 8 +- .../overview/templates/overview/usage.csv | 18 +-- .../dashboards/project/overview/tests.py | 43 +++-- .../dashboards/project/overview/views.py | 27 +++- .../dashboards/project/volumes/tests.py | 12 +- .../test/api_tests/nova_tests.py | 9 +- .../test/test_data/keystone_data.py | 7 +- .../test/test_data/nova_data.py | 21 ++- openstack_dashboard/test/tests/quotas.py | 12 +- openstack_dashboard/usage/__init__.py | 4 +- openstack_dashboard/usage/base.py | 147 +++++++++++++++++- openstack_dashboard/usage/tables.py | 8 +- openstack_dashboard/usage/views.py | 20 +-- 18 files changed, 324 insertions(+), 92 deletions(-) diff --git a/openstack_dashboard/dashboards/admin/instances/tests.py b/openstack_dashboard/dashboards/admin/instances/tests.py index 5b39f4a03..6cc70448a 100644 --- a/openstack_dashboard/dashboards/admin/instances/tests.py +++ b/openstack_dashboard/dashboards/admin/instances/tests.py @@ -171,8 +171,11 @@ class InstanceViewTest(test.BaseAdminViewTests): @test.create_stubs({api.nova: ('flavor_list', 'server_list',), api.keystone: ('tenant_list',)}) def test_index_options_after_migrate(self): - server = self.servers.first() - server.status = "VERIFY_RESIZE" + servers = self.servers.list() + server1 = servers[0] + server1.status = "VERIFY_RESIZE" + server2 = servers[2] + server2.status = "VERIFY_RESIZE" api.keystone.tenant_list(IsA(http.HttpRequest)) \ .AndReturn(self.tenants.list()) search_opts = {'marker': None, 'paginate': True} diff --git a/openstack_dashboard/dashboards/admin/overview/templates/overview/usage.csv b/openstack_dashboard/dashboards/admin/overview/templates/overview/usage.csv index 686129b20..76b65379b 100644 --- a/openstack_dashboard/dashboards/admin/overview/templates/overview/usage.csv +++ b/openstack_dashboard/dashboards/admin/overview/templates/overview/usage.csv @@ -1,10 +1,7 @@ -Usage Report For Period:,{{ usage.start|date:"b. d Y" }},{{ usage.end|date:"b. d Y" }} -Active Instances:,{{ usage.summary.instances }} -CPU-HRs Used:,{{ usage.summary.vcpu_hours|floatformat:2 }} -Total Active RAM (MB):,{{ usage.summary.memory_mb }} -Total Disk Size:,{{ usage.summary.local_gb }} -Total Disk Usage:,{{ usage.summary.disk_gb_hours|floatformat:2 }} +{% load i18n %}{% trans "Usage Report For Period" %}:,{{ usage.start|date:"b. d Y" }},{{ usage.end|date:"b. d Y" }} +{% trans "Active Instances" %}:,{{ usage.summary.instances }} +{% trans "CPU-HRs Used" %}:,{{ usage.summary.vcpu_hours|floatformat:2 }} +{% trans "Total Active RAM (MB)" %}:,{{ usage.summary.memory_mb }} +{% trans "Total Disk Size" %}:,{{ usage.summary.local_gb }} +{% trans "Total Disk Usage" %}:,{{ usage.summary.disk_gb_hours|floatformat:2 }} -Tenant,VCPUs,RamMB,DiskGB,Usage(Hours) -{% for u in usage.usage_list %}{{ u.tenant_id|addslashes }},{{ u.vcpus|addslashes }},{{ u.memory_mb|addslashes }},{{u.local_gb|addslashes }},{{ u.vcpu_hours|floatformat:2}} -{% endfor %} diff --git a/openstack_dashboard/dashboards/admin/overview/tests.py b/openstack_dashboard/dashboards/admin/overview/tests.py index 692888513..3fa11afb6 100644 --- a/openstack_dashboard/dashboards/admin/overview/tests.py +++ b/openstack_dashboard/dashboards/admin/overview/tests.py @@ -38,6 +38,7 @@ INDEX_URL = reverse('horizon:project:overview:index') class UsageViewTests(test.BaseAdminViewTests): + @test.create_stubs({api.nova: ('usage_list',), quotas: ('tenant_quota_usages',), api.keystone: ('tenant_list',)}) @@ -75,24 +76,26 @@ class UsageViewTests(test.BaseAdminViewTests): api.keystone: ('tenant_list',)}) def test_usage_csv(self): now = timezone.now() - usage_obj = api.nova.NovaUsage(self.usages.first()) + usage_obj = [api.nova.NovaUsage(u) for u in self.usages.list()] quota_data = self.quota_usages.first() api.keystone.tenant_list(IsA(http.HttpRequest)) \ .AndReturn(self.tenants.list()) api.nova.usage_list(IsA(http.HttpRequest), datetime.datetime(now.year, now.month, 1, 0, 0, 0), Func(usage.almost_now)) \ - .AndReturn([usage_obj, usage_obj]) + .AndReturn(usage_obj) quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(quota_data) self.mox.ReplayAll() csv_url = reverse('horizon:admin:overview:index') + "?format=csv" res = self.client.get(csv_url) self.assertTemplateUsed(res, 'admin/overview/usage.csv') self.assertTrue(isinstance(res.context['usage'], usage.GlobalUsage)) - hdr = 'Tenant,VCPUs,RamMB,DiskGB,Usage(Hours)' - row = '%s,%s,%s,%s,%.2f' % (usage_obj.tenant_id, - usage_obj.vcpus, - usage_obj.memory_mb, - usage_obj.disk_gb_hours, - usage_obj.vcpu_hours) - self.assertContains(res, '%s\n%s\n%s\n' % (hdr, row, row)) + hdr = 'Project Name,VCPUs,Ram (MB),Disk (GB),Usage (Hours)' + self.assertContains(res, '%s\r\n' % (hdr)) + for obj in usage_obj: + row = u'{0},{1},{2},{3},{4:.2f}\r\n'.format(obj.project_name, + obj.vcpus, + obj.memory_mb, + obj.disk_gb_hours, + obj.vcpu_hours) + self.assertContains(res, row) diff --git a/openstack_dashboard/dashboards/admin/overview/views.py b/openstack_dashboard/dashboards/admin/overview/views.py index a05e6bf68..c837e9dff 100644 --- a/openstack_dashboard/dashboards/admin/overview/views.py +++ b/openstack_dashboard/dashboards/admin/overview/views.py @@ -18,19 +18,38 @@ # License for the specific language governing permissions and limitations # under the License. +from django import VERSION from django.conf import settings +from django.template.defaultfilters import floatformat from django.utils.translation import ugettext_lazy as _ from horizon import exceptions from openstack_dashboard import api from openstack_dashboard import usage +from openstack_dashboard.usage.base import BaseCsvResponse + + +class GlobalUsageCsvRenderer(BaseCsvResponse): + + columns = [_("Project Name"), _("VCPUs"), _("Ram (MB)"), + _("Disk (GB)"), _("Usage (Hours)")] + + def get_row_data(self): + + for u in self.context['usage'].usage_list: + yield (u.project_name or u.tenant_id, + u.vcpus, + u.memory_mb, + u.local_gb, + floatformat(u.vcpu_hours, 2)) class GlobalOverview(usage.UsageView): table_class = usage.GlobalUsageTable usage_class = usage.GlobalUsage template_name = 'admin/overview/usage.html' + csv_response_class = GlobalUsageCsvRenderer def get_context_data(self, **kwargs): context = super(GlobalOverview, self).get_context_data(**kwargs) @@ -39,17 +58,17 @@ class GlobalOverview(usage.UsageView): def get_data(self): data = super(GlobalOverview, self).get_data() - # Pre-fill tenant names + # Pre-fill project names try: - tenants = api.keystone.tenant_list(self.request) + projects = api.keystone.tenant_list(self.request) except: - tenants = [] + projects = [] exceptions.handle(self.request, _('Unable to retrieve project list.')) for instance in data: - tenant = filter(lambda t: t.id == instance.tenant_id, tenants) - if tenant: - instance.tenant_name = getattr(tenant[0], "name", None) + project = filter(lambda t: t.id == instance.tenant_id, projects) + if project: + instance.project_name = getattr(project[0], "name", None) else: - instance.tenant_name = None + instance.project_name = None return data diff --git a/openstack_dashboard/dashboards/admin/projects/urls.py b/openstack_dashboard/dashboards/admin/projects/urls.py index 59369302c..79b93275e 100644 --- a/openstack_dashboard/dashboards/admin/projects/urls.py +++ b/openstack_dashboard/dashboards/admin/projects/urls.py @@ -20,7 +20,7 @@ from django.conf.urls.defaults import patterns, url -from .views import (IndexView, TenantUsageView, +from .views import (IndexView, ProjectUsageView, CreateProjectView, UpdateProjectView, CreateUserView) @@ -31,7 +31,7 @@ urlpatterns = patterns('', url(r'^(?P[^/]+)/update/$', UpdateProjectView.as_view(), name='update'), url(r'^(?P[^/]+)/usage/$', - TenantUsageView.as_view(), name='usage'), + ProjectUsageView.as_view(), name='usage'), url(r'^(?P[^/]+)/create_user/$', CreateUserView.as_view(), name='create_user'), ) diff --git a/openstack_dashboard/dashboards/admin/projects/views.py b/openstack_dashboard/dashboards/admin/projects/views.py index 1017c78f9..3c3bc8328 100644 --- a/openstack_dashboard/dashboards/admin/projects/views.py +++ b/openstack_dashboard/dashboards/admin/projects/views.py @@ -116,13 +116,13 @@ class UsersView(tables.MultiTableView): return context -class TenantUsageView(usage.UsageView): - table_class = usage.TenantUsageTable - usage_class = usage.TenantUsage +class ProjectUsageView(usage.UsageView): + table_class = usage.ProjectUsageTable + usage_class = usage.ProjectUsage template_name = 'admin/projects/usage.html' def get_data(self): - super(TenantUsageView, self).get_data() + super(ProjectUsageView, self).get_data() return self.usage.get_instances() diff --git a/openstack_dashboard/dashboards/project/overview/templates/overview/usage.csv b/openstack_dashboard/dashboards/project/overview/templates/overview/usage.csv index 3e2ecd5b2..0baf1867d 100644 --- a/openstack_dashboard/dashboards/project/overview/templates/overview/usage.csv +++ b/openstack_dashboard/dashboards/project/overview/templates/overview/usage.csv @@ -1,11 +1,9 @@ -Usage Report For Period:,{{ usage.start|date:"b. d Y" }},{{ usage.end|date:"b. d Y" }} -Tenant ID:,{{ usage.tenant_id }} -Total Active VCPUs:,{{ usage.summary.instances }} -CPU-HRs Used:,{{ usage.summary.vcpu_hours|floatformat:2 }} -Total Active Ram (MB):,{{ usage.summary.memory_mb }} -Total Disk Size:,{{ usage.summary.local_gb }} -Total Disk Usage:,{{ usage.summary.disk_gb_hours|floatformat:2 }} +{% load i18n %}{% trans "Usage Report For Period" %}:,{{ usage.start|date:"b. d Y" }},{{ usage.end|date:"b. d Y" }} +{% trans "Project ID" %}:,{{ usage.project_id }} +{% trans "Project Name" %}:,{{ usage.project_name }} +{% trans "Total Active VCPUs" %}:,{{ usage.summary.instances }} +{% trans "CPU-HRs Used" %}:,{{ usage.summary.vcpu_hours|floatformat:2 }} +{% trans "Total Active Ram (MB)" %}:,{{ usage.summary.memory_mb }} +{% trans "Total Disk Size" %}:,{{ usage.summary.local_gb }} +{% trans "Total Disk Usage" %}:,{{ usage.summary.disk_gb_hours|floatformat:2 }} -Name,VCPUs,RamMB,DiskGB,Usage(Hours),Uptime(Seconds),State -{% for s in usage.get_instances %}{{ s.name|addslashes }},{{ s.vcpus|addslashes }},{{ s.memory_mb|addslashes }},{{s.local_gb|addslashes }},{{ s.hours|floatformat:2 }},{{ s.uptime }},{{ s.state|capfirst|addslashes }} -{% endfor %} diff --git a/openstack_dashboard/dashboards/project/overview/tests.py b/openstack_dashboard/dashboards/project/overview/tests.py index 386ce88d2..9506d74ab 100644 --- a/openstack_dashboard/dashboards/project/overview/tests.py +++ b/openstack_dashboard/dashboards/project/overview/tests.py @@ -36,22 +36,27 @@ INDEX_URL = reverse('horizon:project:overview:index') class UsageViewTests(test.TestCase): + + @test.create_stubs({api.nova: ('usage_get',), + quotas: ('tenant_quota_usages',), + api.keystone: ('tenant_get',)}) def test_usage(self): now = timezone.now() usage_obj = api.nova.NovaUsage(self.usages.first()) quota_data = self.quota_usages.first() - self.mox.StubOutWithMock(api.nova, 'usage_get') - self.mox.StubOutWithMock(quotas, 'tenant_quota_usages') + project = self.tenants.first() api.nova.usage_get(IsA(http.HttpRequest), self.tenant.id, datetime.datetime(now.year, now.month, 1, 0, 0, 0), Func(usage.almost_now)) \ .AndReturn(usage_obj) quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(quota_data) + api.keystone.tenant_get(IsA(http.HttpRequest), + project.id).AndReturn(project) self.mox.ReplayAll() res = self.client.get(reverse('horizon:project:overview:index')) self.assertTemplateUsed(res, 'project/overview/usage.html') - self.assertTrue(isinstance(res.context['usage'], usage.TenantUsage)) + self.assertTrue(isinstance(res.context['usage'], usage.ProjectUsage)) self.assertContains(res, 'form-horizontal') def test_unauthorized(self): @@ -73,12 +78,14 @@ class UsageViewTests(test.TestCase): self.assertMessageCount(res, error=1) self.assertContains(res, 'Unauthorized:') + @test.create_stubs({api.nova: ('usage_get',), + quotas: ('tenant_quota_usages',), + api.keystone: ('tenant_get',)}) def test_usage_csv(self): now = timezone.now() usage_obj = api.nova.NovaUsage(self.usages.first()) quota_data = self.quota_usages.first() - self.mox.StubOutWithMock(api.nova, 'usage_get') - self.mox.StubOutWithMock(quotas, 'tenant_quota_usages') + project = self.tenants.first() timestamp = datetime.datetime(now.year, now.month, 1, 0, 0, 0) api.nova.usage_get(IsA(http.HttpRequest), self.tenant.id, @@ -86,18 +93,20 @@ class UsageViewTests(test.TestCase): Func(usage.almost_now)) \ .AndReturn(usage_obj) quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(quota_data) + api.keystone.tenant_get(IsA(http.HttpRequest), + project.id).AndReturn(project) self.mox.ReplayAll() res = self.client.get(reverse('horizon:project:overview:index') + "?format=csv") self.assertTemplateUsed(res, 'project/overview/usage.csv') - self.assertTrue(isinstance(res.context['usage'], usage.TenantUsage)) + self.assertTrue(isinstance(res.context['usage'], usage.ProjectUsage)) + @test.create_stubs({api.nova: ('usage_get',), + quotas: ('tenant_quota_usages',)}) def test_usage_exception_usage(self): now = timezone.now() quota_data = self.quota_usages.first() - self.mox.StubOutWithMock(api.nova, 'usage_get') - self.mox.StubOutWithMock(quotas, 'tenant_quota_usages') timestamp = datetime.datetime(now.year, now.month, 1, 0, 0, 0) api.nova.usage_get(IsA(http.HttpRequest), self.tenant.id, @@ -111,11 +120,13 @@ class UsageViewTests(test.TestCase): self.assertTemplateUsed(res, 'project/overview/usage.html') self.assertEqual(res.context['usage'].usage_list, []) + @test.create_stubs({api.nova: ('usage_get',), + quotas: ('tenant_quota_usages',), + api.keystone: ('tenant_get',)}) def test_usage_exception_quota(self): now = timezone.now() usage_obj = api.nova.NovaUsage(self.usages.first()) - self.mox.StubOutWithMock(api.nova, 'usage_get') - self.mox.StubOutWithMock(quotas, 'tenant_quota_usages') + project = self.tenants.first() timestamp = datetime.datetime(now.year, now.month, 1, 0, 0, 0) api.nova.usage_get(IsA(http.HttpRequest), self.tenant.id, @@ -124,18 +135,22 @@ class UsageViewTests(test.TestCase): .AndReturn(usage_obj) quotas.tenant_quota_usages(IsA(http.HttpRequest))\ .AndRaise(self.exceptions.nova) + api.keystone.tenant_get(IsA(http.HttpRequest), + project.id).AndReturn(project) self.mox.ReplayAll() res = self.client.get(reverse('horizon:project:overview:index')) self.assertTemplateUsed(res, 'project/overview/usage.html') self.assertEqual(res.context['usage'].quotas, {}) + @test.create_stubs({api.nova: ('usage_get',), + quotas: ('tenant_quota_usages',), + api.keystone: ('tenant_get',)}) def test_usage_default_tenant(self): now = timezone.now() usage_obj = api.nova.NovaUsage(self.usages.first()) quota_data = self.quota_usages.first() - self.mox.StubOutWithMock(api.nova, 'usage_get') - self.mox.StubOutWithMock(quotas, 'tenant_quota_usages') + project = self.tenants.first() timestamp = datetime.datetime(now.year, now.month, 1, 0, 0, 0) api.nova.usage_get(IsA(http.HttpRequest), self.tenant.id, @@ -143,8 +158,10 @@ class UsageViewTests(test.TestCase): Func(usage.almost_now)) \ .AndReturn(usage_obj) quotas.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(quota_data) + api.keystone.tenant_get(IsA(http.HttpRequest), + project.id).AndReturn(project) self.mox.ReplayAll() res = self.client.get(reverse('horizon:project:overview:index')) self.assertTemplateUsed(res, 'project/overview/usage.html') - self.assertTrue(isinstance(res.context['usage'], usage.TenantUsage)) + self.assertTrue(isinstance(res.context['usage'], usage.ProjectUsage)) diff --git a/openstack_dashboard/dashboards/project/overview/views.py b/openstack_dashboard/dashboards/project/overview/views.py index 32fc3162f..498b6a12d 100644 --- a/openstack_dashboard/dashboards/project/overview/views.py +++ b/openstack_dashboard/dashboards/project/overview/views.py @@ -18,15 +18,38 @@ # License for the specific language governing permissions and limitations # under the License. +from django import VERSION +from django.template.defaultfilters import floatformat, capfirst +from django.utils.translation import ugettext as _ from django.views.generic import TemplateView from openstack_dashboard import usage +from openstack_dashboard.usage.base import BaseCsvResponse + + +class ProjectUsageCsvRenderer(BaseCsvResponse): + + columns = [_("Instance Name"), _("VCPUs"), _("Ram (MB)"), + _("Disk (GB)"), _("Usage (Hours)"), + _("Uptime(Seconds)"), _("State")] + + def get_row_data(self): + + for inst in self.context['usage'].get_instances(): + yield (inst['name'], + inst['vcpus'], + inst['memory_mb'], + inst['local_gb'], + floatformat(inst['hours'], 2), + inst['uptime'], + capfirst(inst['state'])) class ProjectOverview(usage.UsageView): - table_class = usage.TenantUsageTable - usage_class = usage.TenantUsage + table_class = usage.ProjectUsageTable + usage_class = usage.ProjectUsage template_name = 'project/overview/usage.html' + csv_response_class = ProjectUsageCsvRenderer def get_data(self): super(ProjectOverview, self).get_data() diff --git a/openstack_dashboard/dashboards/project/volumes/tests.py b/openstack_dashboard/dashboards/project/volumes/tests.py index bf8f5f973..10e2cbd1e 100644 --- a/openstack_dashboard/dashboards/project/volumes/tests.py +++ b/openstack_dashboard/dashboards/project/volumes/tests.py @@ -609,7 +609,8 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_get',), api.nova: ('server_list',)}) def test_edit_attachments(self): volume = self.volumes.first() - servers = self.servers.list() + servers = [s for s in self.servers.list() + if s.tenant_id == self.request.user.tenant_id] cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) api.nova.server_list(IsA(http.HttpRequest)).AndReturn([servers, False]) @@ -632,7 +633,8 @@ class VolumeViewTests(test.TestCase): settings.OPENSTACK_HYPERVISOR_FEATURES['can_set_mount_point'] = False volume = self.volumes.first() - servers = self.servers.list() + servers = [s for s in self.servers.list() + if s.tenant_id == self.request.user.tenant_id] cinder.volume_get(IsA(http.HttpRequest), volume.id).AndReturn(volume) api.nova.server_list(IsA(http.HttpRequest)).AndReturn([servers, False]) @@ -649,13 +651,15 @@ class VolumeViewTests(test.TestCase): @test.create_stubs({cinder: ('volume_get',), api.nova: ('server_get', 'server_list',)}) def test_edit_attachments_attached_volume(self): - server = self.servers.first() + servers = [s for s in self.servers.list() + if s.tenant_id == self.request.user.tenant_id] + server = servers[0] volume = self.volumes.list()[0] cinder.volume_get(IsA(http.HttpRequest), volume.id) \ .AndReturn(volume) api.nova.server_list(IsA(http.HttpRequest)) \ - .AndReturn([self.servers.list(), False]) + .AndReturn([servers, False]) self.mox.ReplayAll() diff --git a/openstack_dashboard/test/api_tests/nova_tests.py b/openstack_dashboard/test/api_tests/nova_tests.py index 71e354f8e..16b67b13b 100644 --- a/openstack_dashboard/test/api_tests/nova_tests.py +++ b/openstack_dashboard/test/api_tests/nova_tests.py @@ -33,6 +33,7 @@ from openstack_dashboard.test import helpers as test class ServerWrapperTests(test.TestCase): + def test_get_base_attribute(self): server = api.nova.Server(self.servers.first(), self.request) self.assertEqual(server.id, self.servers.first().id) @@ -41,7 +42,7 @@ class ServerWrapperTests(test.TestCase): image = self.images.first() self.mox.StubOutWithMock(api.glance, 'image_get') api.glance.image_get(IsA(http.HttpRequest), - image.id).AndReturn(image) + image.id).AndReturn(image) self.mox.ReplayAll() server = api.nova.Server(self.servers.first(), self.request) @@ -49,6 +50,7 @@ class ServerWrapperTests(test.TestCase): class ComputeApiTests(test.APITestCase): + def test_server_reboot(self): server = self.servers.first() HARDNESS = servers.REBOOT_HARD @@ -99,7 +101,7 @@ class ComputeApiTests(test.APITestCase): novaclient = self.stub_novaclient() novaclient.servers = self.mox.CreateMockAnything() novaclient.servers.get_spice_console(server.id, - console_type).AndReturn(console) + console_type).AndReturn(console) self.mox.ReplayAll() ret_val = api.nova.server_spice_console(self.request, @@ -148,7 +150,8 @@ class ComputeApiTests(test.APITestCase): novaclient.servers.list(True, {'all_tenants': True, 'marker': None, - 'limit': page_size + 1}).AndReturn(servers) + 'limit': page_size + 1}). \ + AndReturn(servers[:page_size + 1]) self.mox.ReplayAll() ret_val, has_more = api.nova.server_list(self.request, diff --git a/openstack_dashboard/test/test_data/keystone_data.py b/openstack_dashboard/test/test_data/keystone_data.py index ea61f8188..d75fb6eee 100644 --- a/openstack_dashboard/test/test_data/keystone_data.py +++ b/openstack_dashboard/test/test_data/keystone_data.py @@ -139,9 +139,14 @@ def data(TEST): 'name': 'disabled_tenant', 'description': "a disabled test tenant.", 'enabled': False} + tenant_dict_unicode = {'id': "3", + 'name': u'\u4e91\u89c4\u5219', + 'description': "an unicode-named tenant.", + 'enabled': True} tenant = tenants.Tenant(tenants.TenantManager, tenant_dict) disabled_tenant = tenants.Tenant(tenants.TenantManager, tenant_dict_2) - TEST.tenants.add(tenant, disabled_tenant) + tenant_unicode = tenants.Tenant(tenants.TenantManager, tenant_dict_unicode) + TEST.tenants.add(tenant, disabled_tenant, tenant_unicode) TEST.tenant = tenant # Your "current" tenant tomorrow = datetime_safe.datetime.now() + timedelta(days=1) diff --git a/openstack_dashboard/test/test_data/nova_data.py b/openstack_dashboard/test/test_data/nova_data.py index 992533241..01bf8a132 100644 --- a/openstack_dashboard/test/test_data/nova_data.py +++ b/openstack_dashboard/test/test_data/nova_data.py @@ -361,6 +361,8 @@ def data(TEST): TEST.limits = limits # Servers + tenant3 = TEST.tenants.list()[2] + vals = {"host": "http://nova.example.com:8774", "name": "server_1", "status": "ACTIVE", @@ -377,7 +379,13 @@ def data(TEST): "server_id": "2"}) server_2 = servers.Server(servers.ServerManager(None), json.loads(SERVER_DATA % vals)['server']) - TEST.servers.add(server_1, server_2) + vals.update({"name": u'\u4e91\u89c4\u5219', + "status": "ACTIVE", + "tenant_id": tenant3.id, + "server_id": "3"}) + server_3 = servers.Server(servers.ServerManager(None), + json.loads(SERVER_DATA % vals)['server']) + TEST.servers.add(server_1, server_2, server_3) # VNC Console Data console = {u'console': {u'url': u'http://example.com:6080/vnc_auto.html', @@ -434,6 +442,17 @@ def data(TEST): json.loads(USAGE_DATA % usage_vals)) TEST.usages.add(usage_obj) + # Usage + usage_2_vals = {"tenant_id": tenant3.id, + "instance_name": server_3.name, + "flavor_name": flavor_1.name, + "flavor_vcpus": flavor_1.vcpus, + "flavor_disk": flavor_1.disk, + "flavor_ram": flavor_1.ram} + usage_obj_2 = usage.Usage(usage.UsageManager(None), + json.loads(USAGE_DATA % usage_2_vals)) + TEST.usages.add(usage_obj_2) + volume_snapshot = vol_snaps.Snapshot(vol_snaps.SnapshotManager(None), {'id': '40f3fabf-3613-4f5e-90e5-6c9a08333fc3', 'display_name': 'test snapshot', diff --git a/openstack_dashboard/test/tests/quotas.py b/openstack_dashboard/test/tests/quotas.py index 1c65f40ca..db6b7f912 100644 --- a/openstack_dashboard/test/tests/quotas.py +++ b/openstack_dashboard/test/tests/quotas.py @@ -56,6 +56,8 @@ class QuotaTests(test.APITestCase): quotas: ('is_service_enabled',), cinder: ('volume_list', 'tenant_quota_get',)}) def test_tenant_quota_usages(self): + servers = [s for s in self.servers.list() + if s.tenant_id == self.request.user.tenant_id] quotas.is_service_enabled(IsA(http.HttpRequest), 'volume').AndReturn(True) api.nova.flavor_list(IsA(http.HttpRequest)) \ @@ -65,7 +67,7 @@ class QuotaTests(test.APITestCase): api.network.tenant_floating_ip_list(IsA(http.HttpRequest)) \ .AndReturn(self.floating_ips.list()) api.nova.server_list(IsA(http.HttpRequest)) \ - .AndReturn([self.servers.list(), False]) + .AndReturn([servers, False]) cinder.volume_list(IsA(http.HttpRequest)) \ .AndReturn(self.volumes.list()) cinder.tenant_quota_get(IsA(http.HttpRequest), '1') \ @@ -85,6 +87,8 @@ class QuotaTests(test.APITestCase): api.network: ('tenant_floating_ip_list',), quotas: ('is_service_enabled',)}) def test_tenant_quota_usages_without_volume(self): + servers = [s for s in self.servers.list() + if s.tenant_id == self.request.user.tenant_id] quotas.is_service_enabled(IsA(http.HttpRequest), 'volume').AndReturn(False) api.nova.flavor_list(IsA(http.HttpRequest)) \ @@ -94,7 +98,7 @@ class QuotaTests(test.APITestCase): api.network.tenant_floating_ip_list(IsA(http.HttpRequest)) \ .AndReturn(self.floating_ips.list()) api.nova.server_list(IsA(http.HttpRequest)) \ - .AndReturn([self.servers.list(), False]) + .AndReturn([servers, False]) self.mox.ReplayAll() @@ -143,6 +147,8 @@ class QuotaTests(test.APITestCase): def test_tenant_quota_usages_unlimited_quota(self): inf_quota = self.quotas.first() inf_quota['ram'] = -1 + servers = [s for s in self.servers.list() + if s.tenant_id == self.request.user.tenant_id] quotas.is_service_enabled(IsA(http.HttpRequest), 'volume').AndReturn(True) @@ -153,7 +159,7 @@ class QuotaTests(test.APITestCase): api.network.tenant_floating_ip_list(IsA(http.HttpRequest)) \ .AndReturn(self.floating_ips.list()) api.nova.server_list(IsA(http.HttpRequest)) \ - .AndReturn([self.servers.list(), False]) + .AndReturn([servers, False]) cinder.volume_list(IsA(http.HttpRequest)) \ .AndReturn(self.volumes.list()) cinder.tenant_quota_get(IsA(http.HttpRequest), '1') \ diff --git a/openstack_dashboard/usage/__init__.py b/openstack_dashboard/usage/__init__.py index e74fc056a..2edc6445c 100644 --- a/openstack_dashboard/usage/__init__.py +++ b/openstack_dashboard/usage/__init__.py @@ -14,6 +14,6 @@ # License for the specific language governing permissions and limitations # under the License. -from .base import BaseUsage, TenantUsage, GlobalUsage, almost_now +from .base import BaseUsage, ProjectUsage, GlobalUsage, almost_now from .views import UsageView -from .tables import BaseUsageTable, TenantUsageTable, GlobalUsageTable +from .tables import BaseUsageTable, ProjectUsageTable, GlobalUsageTable diff --git a/openstack_dashboard/usage/base.py b/openstack_dashboard/usage/base.py index 829a85bfe..cae12c132 100644 --- a/openstack_dashboard/usage/base.py +++ b/openstack_dashboard/usage/base.py @@ -1,9 +1,13 @@ from __future__ import division from calendar import monthrange +from csv import writer, DictWriter import datetime import logging +from StringIO import StringIO +from django import template as django_template, VERSION +from django.http.response import HttpResponse from django.utils.translation import ugettext_lazy as _ from django.utils import timezone @@ -27,8 +31,8 @@ def almost_now(input_time): class BaseUsage(object): show_terminated = False - def __init__(self, request, tenant_id=None): - self.tenant_id = tenant_id or request.user.tenant_id + def __init__(self, request, project_id=None): + self.project_id = project_id or request.user.tenant_id self.request = request self.summary = {} self.usage_list = [] @@ -101,9 +105,9 @@ class BaseUsage(object): _("You are viewing data for the future, " "which may or may not exist.")) - for tenant_usage in self.usage_list: - tenant_summary = tenant_usage.get_summary() - for key, value in tenant_summary.items(): + for project_usage in self.usage_list: + project_summary = project_usage.get_summary() + for key, value in project_summary.items(): self.summary.setdefault(key, 0) self.summary[key] += value @@ -130,7 +134,7 @@ class GlobalUsage(BaseUsage): return api.nova.usage_list(self.request, start, end) -class TenantUsage(BaseUsage): +class ProjectUsage(BaseUsage): attrs = ('memory_mb', 'vcpus', 'uptime', 'hours', 'local_gb') @@ -139,7 +143,9 @@ class TenantUsage(BaseUsage): self.show_terminated) instances = [] terminated_instances = [] - usage = api.nova.usage_get(self.request, self.tenant_id, start, end) + usage = api.nova.usage_get(self.request, self.project_id, start, end) + project = api.keystone.tenant_get(self.request, self.project_id) + self.project_name = project.name # Attribute may not exist if there are no instances if hasattr(usage, 'server_usages'): now = self.today @@ -155,3 +161,130 @@ class TenantUsage(BaseUsage): instances.append(server_usage) usage.server_usages = instances return (usage,) + + +class CsvDataMixin(object): + + """ + CSV data Mixin - provides handling for CSV data + + .. attribute:: columns + + A list of CSV column definitions. If omitted - no column titles + will be shown in the result file. Optional. + """ + def __init__(self): + self.out = StringIO() + super(CsvDataMixin, self).__init__() + if hasattr(self, "columns"): + self.writer = DictWriter(self.out, map(self.encode, self.columns)) + self.is_dict = True + else: + self.writer = writer(self.out) + self.is_dict = False + + def write_csv_header(self): + if self.is_dict: + try: + self.writer.writeheader() + except AttributeError: + # For Python<2.7 + self.writer.writerow(dict(zip( + self.writer.fieldnames, + self.writer.fieldnames))) + + def write_csv_row(self, args): + if self.is_dict: + self.writer.writerow(dict(zip( + self.writer.fieldnames, map(self.encode, args)))) + else: + self.writer.writerow(map(self.encode, args)) + + def encode(self, value): + # csv and StringIO cannot work with mixed encodings, + # so encode all with utf-8 + return unicode(value).encode('utf-8') + + +class BaseCsvResponse(CsvDataMixin, HttpResponse): + + """ + Base CSV response class. Provides handling of CSV data. + + """ + + def __init__(self, request, template, context, content_type, **kwargs): + super(BaseCsvResponse, self).__init__() + self['Content-Disposition'] = 'attachment; filename="%s"' % ( + kwargs.get("filename", "export.csv"),) + self['Content-Type'] = content_type + self.context = context + self.header = None + if template: + # Display some header info if provided as a template + header_template = django_template.loader.get_template(template) + context = django_template.RequestContext(request, self.context) + self.header = header_template.render(context) + + if self.header: + self.out.write(self.encode(self.header)) + + self.write_csv_header() + + for row in self.get_row_data(): + self.write_csv_row(row) + + self.out.flush() + self.content = self.out.getvalue() + self.out.close() + + def get_row_data(self): + raise NotImplementedError("You must define a get_row_data method on %s" + % self.__class__.__name__) + +if VERSION >= (1, 5, 0): + + from django.http import StreamingHttpResponse + + class BaseCsvStreamingResponse(CsvDataMixin, StreamingHttpResponse): + + """ + Base CSV Streaming class. Provides streaming response for CSV data. + """ + + def __init__(self, request, template, context, content_type, **kwargs): + super(BaseCsvStreamingResponse, self).__init__() + self['Content-Disposition'] = 'attachment; filename="%s"' % ( + kwargs.get("filename", "export.csv"),) + self['Content-Type'] = content_type + self.context = context + self.header = None + if template: + # Display some header info if provided as a template + header_template = django_template.loader.get_template(template) + context = django_template.RequestContext(request, self.context) + self.header = header_template.render(context) + + self._closable_objects.append(self.out) + + self.streaming_content = self.get_content() + + def buffer(self): + buf = self.out.getvalue() + self.out.truncate(0) + return buf + + def get_content(self): + if self.header: + self.out.write(self.encode(self.header)) + + self.write_csv_header() + yield self.buffer() + + for row in self.get_row_data(): + self.write_csv_row(row) + yield self.buffer() + + def get_row_data(self): + raise NotImplementedError("You must define a get_row_data method " + "on %s" % self.__class__.__name__) diff --git a/openstack_dashboard/usage/tables.py b/openstack_dashboard/usage/tables.py index 5df8342a6..cc61c839e 100644 --- a/openstack_dashboard/usage/tables.py +++ b/openstack_dashboard/usage/tables.py @@ -27,7 +27,7 @@ class BaseUsageTable(tables.DataTable): class GlobalUsageTable(BaseUsageTable): - tenant = tables.Column('tenant_name', verbose_name=_("Project Name")) + project = tables.Column('project_name', verbose_name=_("Project Name")) disk_hours = tables.Column('disk_gb_hours', verbose_name=_("Disk GB Hours"), filters=(lambda v: floatformat(v, 2),)) @@ -38,7 +38,7 @@ class GlobalUsageTable(BaseUsageTable): class Meta: name = "global_usage" verbose_name = _("Usage Summary") - columns = ("tenant", "vcpus", "disk", "memory", + columns = ("project", "vcpus", "disk", "memory", "hours", "disk_hours") table_actions = (CSVSummary,) multi_select = False @@ -52,7 +52,7 @@ def get_instance_link(datum): return None -class TenantUsageTable(BaseUsageTable): +class ProjectUsageTable(BaseUsageTable): instance = tables.Column('name', verbose_name=_("Instance Name"), link=get_instance_link) @@ -64,7 +64,7 @@ class TenantUsageTable(BaseUsageTable): return datum.get('instance_id', id(datum)) class Meta: - name = "tenant_usage" + name = "project_usage" verbose_name = _("Usage Summary") columns = ("instance", "vcpus", "disk", "memory", "uptime") table_actions = (CSVSummary,) diff --git a/openstack_dashboard/usage/views.py b/openstack_dashboard/usage/views.py index bb6cf635c..953eccfb3 100644 --- a/openstack_dashboard/usage/views.py +++ b/openstack_dashboard/usage/views.py @@ -28,8 +28,8 @@ class UsageView(tables.DataTableView): return "text/html" def get_data(self): - tenant_id = self.kwargs.get('tenant_id', self.request.user.tenant_id) - self.usage = self.usage_class(self.request, tenant_id) + project_id = self.kwargs.get('project_id', self.request.user.tenant_id) + self.usage = self.usage_class(self.request, project_id) self.usage.summarize(*self.usage.get_date_range()) self.usage.get_quotas() self.kwargs['usage'] = self.usage @@ -43,12 +43,14 @@ class UsageView(tables.DataTableView): return context def render_to_response(self, context, **response_kwargs): - resp = self.response_class(request=self.request, - template=self.get_template_names(), - context=context, - content_type=self.get_content_type(), - **response_kwargs) if self.request.GET.get('format', 'html') == 'csv': - resp['Content-Disposition'] = 'attachment; filename=usage.csv' - resp['Content-Type'] = 'text/csv' + render_class = self.csv_response_class + response_kwargs.setdefault("filename", "usage.csv") + else: + render_class = self.response_class + resp = render_class(request=self.request, + template=self.get_template_names(), + context=context, + content_type=self.get_content_type(), + **response_kwargs) return resp