Refactor metering dashboard
- check date range for validity - use django forms - treat times according to users timezone Change-Id: I68182ddb4b00d19e54011722ebc770d9aa8e725c Closes-bug: #1331427 Closes-bug: #1331434 Closes-bug: #1288512
This commit is contained in:
parent
d471bae2cc
commit
ca2e10c3e8
68
openstack_dashboard/dashboards/admin/metering/forms.py
Normal file
68
openstack_dashboard/dashboards/admin/metering/forms.py
Normal file
@ -0,0 +1,68 @@
|
||||
# Copyright 2014 OpenStack Foundation
|
||||
#
|
||||
# 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.
|
||||
|
||||
import datetime
|
||||
|
||||
from django.forms import ValidationError # noqa
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import forms
|
||||
|
||||
|
||||
class UsageReportForm(forms.SelfHandlingForm):
|
||||
PERIOD_CHOICES = (("1", _("Last day")),
|
||||
("7", _("Last week")),
|
||||
(str(datetime.date.today().day), _("Month to date")),
|
||||
("15", _("Last 15 days")),
|
||||
("30", _("Last 30 days")),
|
||||
("365", _("Last year")),
|
||||
("other", _("Other")),
|
||||
)
|
||||
period = forms.ChoiceField(label=_("Period"),
|
||||
required=True,
|
||||
choices=PERIOD_CHOICES)
|
||||
date_from = forms.DateField(label=_("From"), required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={'data-line-chart-command':
|
||||
'date_picker_change'}))
|
||||
date_to = forms.DateField(label=_("To"), required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={'data-line-chart-command':
|
||||
'date_picker_change'}))
|
||||
|
||||
def clean_date_from(self):
|
||||
period = self.cleaned_data['period']
|
||||
date_from = self.cleaned_data['date_from']
|
||||
if period == 'other' and date_from is None:
|
||||
raise ValidationError(_('Must specify start of period'))
|
||||
return date_from
|
||||
|
||||
def clean_date_to(self):
|
||||
data = super(UsageReportForm, self).clean()
|
||||
date_from = data.get('date_from')
|
||||
date_to = data.get('date_to')
|
||||
period = data.get('period')
|
||||
if (period == 'other' and date_to is not None
|
||||
and date_from is not None and date_to < date_from):
|
||||
raise ValidationError(_("Start must be earlier "
|
||||
"than end of period."))
|
||||
else:
|
||||
return date_to
|
||||
|
||||
def handle(self, request, data):
|
||||
if hasattr(request, 'session'):
|
||||
request.session['date_from'] = data['date_from']
|
||||
request.session['date_to'] = data['date_to']
|
||||
request.session['period'] = data['period']
|
||||
return data
|
@ -21,6 +21,42 @@ def show_date(datum):
|
||||
return datum.split('T')[0]
|
||||
|
||||
|
||||
class ModifyUsageReportParameters(tables.LinkAction):
|
||||
name = "create"
|
||||
verbose_name = _("Modify Usage Report Parameters")
|
||||
url = "horizon:admin:metering:create"
|
||||
classes = ("btn-create",)
|
||||
icon = "edit"
|
||||
|
||||
|
||||
class CreateCSVUsageReport(tables.LinkAction):
|
||||
name = "csv"
|
||||
verbose_name = _("Download CSV Summary")
|
||||
url = "horizon:admin:metering:csvreport"
|
||||
classes = ("btn-create",)
|
||||
icon = "download"
|
||||
|
||||
|
||||
class ReportTable(tables.DataTable):
|
||||
project = tables.Column('project', verbose_name=_('Project'))
|
||||
service = tables.Column('service', verbose_name=_('Service'))
|
||||
meter = tables.Column('meter', verbose_name=_('Meter'))
|
||||
description = tables.Column('description', verbose_name=_('Description'))
|
||||
time = tables.Column('time', verbose_name=_('Day'),
|
||||
filters=[show_date])
|
||||
value = tables.Column('value', verbose_name=_('Value (Avg)'),
|
||||
filters=[humanize.intcomma])
|
||||
|
||||
def get_object_id(self, obj):
|
||||
return "%s-%s-%s" % (obj['project'], obj['service'], obj['meter'])
|
||||
|
||||
class Meta:
|
||||
name = 'report_table'
|
||||
verbose_name = _("Daily Usage Report")
|
||||
table_actions = (ModifyUsageReportParameters, CreateCSVUsageReport)
|
||||
multi_select = False
|
||||
|
||||
|
||||
class UsageTable(tables.DataTable):
|
||||
service = tables.Column('service', verbose_name=_('Service'))
|
||||
meter = tables.Column('meter', verbose_name=_('Meter'))
|
||||
@ -30,6 +66,10 @@ class UsageTable(tables.DataTable):
|
||||
value = tables.Column('value', verbose_name=_('Value (Avg)'),
|
||||
filters=[humanize.intcomma])
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super(UsageTable, self).__init__(request, *args, **kwargs)
|
||||
self.title = getattr(self, 'title', None)
|
||||
|
||||
def get_object_id(self, datum):
|
||||
return datum['time'] + datum['meter']
|
||||
|
||||
|
@ -10,30 +10,27 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from django import template
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import exceptions
|
||||
from horizon import messages
|
||||
from horizon import tabs
|
||||
|
||||
from openstack_dashboard import api
|
||||
from openstack_dashboard.api import ceilometer
|
||||
|
||||
from openstack_dashboard.dashboards.admin.metering import \
|
||||
tables as metering_tables
|
||||
|
||||
class GlobalStatsTab(tabs.Tab):
|
||||
from openstack_dashboard.utils import metering
|
||||
|
||||
|
||||
class GlobalStatsTab(tabs.TableTab):
|
||||
name = _("Stats")
|
||||
slug = "stats"
|
||||
template_name = ("admin/metering/stats.html")
|
||||
preload = False
|
||||
|
||||
@staticmethod
|
||||
def _get_flavor_names(request):
|
||||
try:
|
||||
flavors = api.nova.flavor_list(request, None)
|
||||
return [f.name for f in flavors]
|
||||
except Exception:
|
||||
return ['m1.tiny', 'm1.small', 'm1.medium',
|
||||
'm1.large', 'm1.xlarge']
|
||||
table_classes = (metering_tables.UsageTable,)
|
||||
|
||||
def get_context_data(self, request):
|
||||
meters = ceilometer.Meters(request)
|
||||
@ -53,17 +50,66 @@ class GlobalStatsTab(tabs.Tab):
|
||||
return context
|
||||
|
||||
|
||||
class DailyReportTab(tabs.Tab):
|
||||
name = _("Daily Report")
|
||||
slug = "daily_report"
|
||||
template_name = ("admin/metering/daily.html")
|
||||
class UsageReportTab(tabs.TableTab):
|
||||
name = _("Usage Report")
|
||||
slug = "usage_report"
|
||||
template_name = "horizon/common/_detail_table.html"
|
||||
table_classes = (metering_tables.ReportTable,)
|
||||
|
||||
def get_context_data(self, request):
|
||||
context = template.RequestContext(request)
|
||||
return context
|
||||
def get_report_table_data(self):
|
||||
meters = ceilometer.Meters(self.request)
|
||||
services = {
|
||||
_('Nova'): meters.list_nova(),
|
||||
_('Neutron'): meters.list_neutron(),
|
||||
_('Glance'): meters.list_glance(),
|
||||
_('Cinder'): meters.list_cinder(),
|
||||
_('Swift_meters'): meters.list_swift(),
|
||||
_('Kwapi'): meters.list_kwapi(),
|
||||
}
|
||||
report_rows = []
|
||||
|
||||
date_options = self.request.session.get('period', 7)
|
||||
date_from = self.request.session.get('date_from', '')
|
||||
date_to = self.request.session.get('date_to', '')
|
||||
|
||||
try:
|
||||
date_from, date_to = metering.calc_date_args(date_from,
|
||||
date_to,
|
||||
date_options)
|
||||
except Exception:
|
||||
exceptions.handle(self.request, _('Dates cannot be recognized.'))
|
||||
try:
|
||||
project_aggregates = metering.ProjectAggregatesQuery(self.request,
|
||||
date_from,
|
||||
date_to,
|
||||
3600 * 24)
|
||||
except Exception:
|
||||
exceptions.handle(self.request,
|
||||
_('Unable to retrieve project list.'))
|
||||
for meter in meters._cached_meters.values():
|
||||
service = None
|
||||
for name, m_list in services.items():
|
||||
if meter in m_list:
|
||||
service = name
|
||||
break
|
||||
res, unit = project_aggregates.query(meter.name)
|
||||
|
||||
for re in res:
|
||||
values = re.get_meter(meter.name.replace(".", "_"))
|
||||
if values:
|
||||
for value in values:
|
||||
row = {"name": 'none',
|
||||
"project": re.id,
|
||||
"meter": meter.name,
|
||||
"description": meter.description,
|
||||
"service": service,
|
||||
"time": value._apiresource.period_end,
|
||||
"value": value._apiresource.avg}
|
||||
report_rows.append(row)
|
||||
return report_rows
|
||||
|
||||
|
||||
class CeilometerOverviewTabs(tabs.TabGroup):
|
||||
slug = "ceilometer_overview"
|
||||
tabs = (DailyReportTab, GlobalStatsTab, )
|
||||
tabs = (UsageReportTab, GlobalStatsTab,)
|
||||
sticky = True
|
||||
|
@ -0,0 +1,57 @@
|
||||
{% extends "horizon/common/_modal_form.html" %}
|
||||
{% load i18n %}
|
||||
{% load url from future %}
|
||||
|
||||
{% block form_id %}create_usage_report_form{% endblock %}
|
||||
{% block form_action %}{% url 'horizon:admin:metering:create' %}{% endblock %}
|
||||
|
||||
{% block modal_id %}create_usage_report_modal{% endblock %}
|
||||
{% block modal-header %}{% trans "Modify Usage Report Parameters" %}{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
<div class="left">
|
||||
<fieldset>
|
||||
{% include "horizon/common/_form_fields.html" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="right">
|
||||
<h3>{% trans "Description:" %}</h3>
|
||||
<p>{% trans "Select a pre-defined period or specify date." %}</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<input class="btn btn-primary pull-right" type="submit" value="{% trans "View Usage Report" %}" />
|
||||
<a href="{% url 'horizon:admin:metering:index' %}" class="btn btn-default secondary cancel close">{% trans "Cancel" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-js %}
|
||||
<script type="text/javascript">
|
||||
if (typeof $ !== 'undefined') {
|
||||
show_hide_datepickers();
|
||||
} else {
|
||||
addHorizonLoadEvent(function() {
|
||||
show_hide_datepickers();
|
||||
});
|
||||
}
|
||||
|
||||
function show_hide_datepickers() {
|
||||
$("#id_period").change(function(evt) {
|
||||
// Enhancing behaviour of selectbox, on 'other' value selected, I don't
|
||||
// want to refresh, but show hide the date fields
|
||||
if ($(this).find("option:selected").val() === "other"){
|
||||
evt.stopPropagation();
|
||||
$("#date_from .controls input, #date_to .controls input").val('');
|
||||
$("#id_date_from, #id_date_to").parent().parent().show();
|
||||
} else {
|
||||
$("#id_date_from, #id_date_to").parent().parent().hide();
|
||||
}
|
||||
});
|
||||
if ($("#id_period").find("option:selected").val() === "other"){
|
||||
$("#id_date_from, #id_date_to").parent().parent().show();
|
||||
} else {
|
||||
$("#id_date_from, #id_date_to").parent().parent().hide();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,74 +1,11 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
<div id="ceilometer-report">
|
||||
<form class="form-horizontal" action="{% url 'horizon:admin:metering:report' %}" method="POST">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="report_date_options" class="control-label col-sm-2">{% trans "Period:" %}</label>
|
||||
<div class="col-sm-2">
|
||||
<select data-line-chart-command="select_box_change"
|
||||
id="report_date_options" name="date_options" class="form-control">
|
||||
<option value="1">{% trans "Last day" %}</option>
|
||||
<option value="7" selected="selected">{% trans "Last week" %}</option>
|
||||
<option value="{% now 'j' %}">{% trans "Month to date" %}</option>
|
||||
<option value="15">{% trans "Last 15 days" %}</option>
|
||||
<option value="30">{% trans "Last 30 days" %}</option>
|
||||
<option value="365">{% trans "Last year" %}</option>
|
||||
<option value="other">{% trans "Other" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="report_date_from">
|
||||
<label for="date_from" class="control-label col-sm-2">{% trans "From:" %}</label>
|
||||
<div class="col-sm-2">
|
||||
<input data-line-chart-command="date_picker_change"
|
||||
type="text" id="date_from" name="date_from" class="form-control example"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="report_date_to">
|
||||
<label for="date_to" class="control-label col-sm-2">{% trans "To:" %}</label>
|
||||
<div class="col-sm-2">
|
||||
<input data-line-chart-command="date_picker_change"
|
||||
type="text" name="date_to" class="form-control example"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="limit" class="control-label col-sm-2">{% trans "Limit project count:" %}</label>
|
||||
<div class="col-sm-2">
|
||||
<input type="text" name="limit" class="form-control example" value="20"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-default btn-sm">{% trans 'Generate Report' %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
if (typeof $ !== 'undefined') {
|
||||
show_hide_datepickers();
|
||||
} else {
|
||||
addHorizonLoadEvent(function() {
|
||||
show_hide_datepickers();
|
||||
});
|
||||
}
|
||||
{% block title %}{% trans "Modify Usage Report Parameters" %}{% endblock %}
|
||||
|
||||
function show_hide_datepickers() {
|
||||
$("#report_date_options").change(function(evt) {
|
||||
// Enhancing behaviour of selectbox, on 'other' value selected, I don't
|
||||
// want to refresh, but show hide the date fields
|
||||
if ($(this).find("option:selected").val() == "other"){
|
||||
evt.stopPropagation();
|
||||
$("#date_from input, #date_to input").val('');
|
||||
$("#report_date_from, #report_date_to").show();
|
||||
} else {
|
||||
$("#report_date_from, #report_date_to").hide();
|
||||
}
|
||||
});
|
||||
if ($("#report_date_options").find("option:selected").val() == "other"){
|
||||
$("#report_date_from, #report_date_to").show();
|
||||
} else {
|
||||
$("#report_date_from, #report_date_to").hide();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% block page_header %}
|
||||
{% include "horizon/common/_page_header.html" with title=_("Modify Usage Report Parameters") %}
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block main %}
|
||||
{% include "admin/metering/_daily.html" %}
|
||||
{% endblock %}
|
||||
|
@ -1,24 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load url from future %}
|
||||
{% block title %}{% trans 'Usage Report' %}{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
{% include "horizon/common/_page_header.html" with title=_("Daily Usage Report Per Project")%}
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block main %}
|
||||
<div class="table_actions">
|
||||
<a href="{{ csv_url }}"><button class="btn btn-default btn-sm" ><span class="glyphicon glyphicon-download"></span>{% trans 'Download CSV Summary' %}</button></a>
|
||||
<a href="{% url 'horizon:admin:metering:index' %}"><button class="btn btn-default btn-sm">{% trans 'Back' %}</button></a>
|
||||
</div>
|
||||
{% for table in tables %}
|
||||
{{ table.render }}
|
||||
{% endfor %}
|
||||
{% if not tables %}
|
||||
<p>
|
||||
{% trans 'No data available' %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
@ -10,41 +10,56 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django import http
|
||||
|
||||
from mox import IsA # noqa
|
||||
|
||||
from openstack_dashboard import api
|
||||
from openstack_dashboard.dashboards.admin.metering import tabs
|
||||
from openstack_dashboard.test import helpers as test
|
||||
|
||||
INDEX_URL = reverse("horizon:admin:metering:index")
|
||||
from openstack_dashboard.test.test_data import utils as test_utils
|
||||
|
||||
|
||||
class MeteringViewTests(test.APITestCase, test.BaseAdminViewTests):
|
||||
def test_stats_page(self):
|
||||
meters = self.meters.list()
|
||||
INDEX_URL = reverse('horizon:admin:metering:index')
|
||||
CREATE_URL = reverse('horizon:admin:metering:create')
|
||||
SAMPLES_URL = reverse('horizon:admin:metering:samples')
|
||||
|
||||
ceilometerclient = self.stub_ceilometerclient()
|
||||
ceilometerclient.meters = self.mox.CreateMockAnything()
|
||||
ceilometerclient.meters.list(None).AndReturn(meters)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
# getting all resources and with statistics
|
||||
res = self.client.get(reverse('horizon:admin:metering:index') +
|
||||
"?tab=ceilometer_overview__stats")
|
||||
self.assertTemplateUsed(res, 'admin/metering/index.html')
|
||||
self.assertTemplateUsed(res, 'admin/metering/stats.html')
|
||||
|
||||
def test_report_page(self):
|
||||
# getting report page with no api access
|
||||
res = self.client.get(reverse('horizon:admin:metering:index') +
|
||||
"?tab=ceilometer_overview__daily_report")
|
||||
self.assertTemplateUsed(res, 'admin/metering/index.html')
|
||||
class MeteringViewTests(test.BaseAdminViewTests):
|
||||
def test_create_report_page(self):
|
||||
formData = {'period': 7}
|
||||
res = self.client.get(CREATE_URL)
|
||||
self.assertTemplateUsed(res, 'admin/metering/daily.html')
|
||||
res = self.client.post(CREATE_URL, formData)
|
||||
self.assertNoFormErrors(res)
|
||||
self.assertRedirectsNoFollow(res, INDEX_URL)
|
||||
|
||||
def test_create_report_dates_messed_up(self):
|
||||
# dates are swapped in create report form
|
||||
formData = {'period': 'other',
|
||||
'date_to': '2014-01-01',
|
||||
'date_from': '2014-02-02'}
|
||||
|
||||
res = self.client.post(CREATE_URL, formData)
|
||||
self.assertFormError(res, "form", "date_to",
|
||||
['Start must be earlier than end of period.'])
|
||||
|
||||
def test_create_report_date_missing(self):
|
||||
formData = {'period': 'other',
|
||||
'date_to': '2014-01-01',
|
||||
'date_from': ''}
|
||||
|
||||
res = self.client.post(CREATE_URL, formData)
|
||||
self.assertFormError(res, "form", "date_from",
|
||||
['Must specify start of period'])
|
||||
|
||||
|
||||
class MeteringLineChartTabTests(test.BaseAdminViewTests):
|
||||
def setUp(self):
|
||||
test.BaseAdminViewTests.setUp(self)
|
||||
self.testdata = test_utils.TestData()
|
||||
test_utils.load_test_data(self.testdata)
|
||||
|
||||
def _verify_series(self, series, value, date, expected_names):
|
||||
data = json.loads(series)
|
||||
@ -61,22 +76,22 @@ class MeteringViewTests(test.APITestCase, test.BaseAdminViewTests):
|
||||
|
||||
self.assertEqual(data.get('settings'), {})
|
||||
|
||||
@test.create_stubs({api.keystone: ('tenant_list',)})
|
||||
@test.create_stubs({api.keystone: ('tenant_list',),
|
||||
api.ceilometer: ('meter_list',
|
||||
'statistic_list',
|
||||
), })
|
||||
def test_stats_for_line_chart(self):
|
||||
statistics = self.statistics.list()
|
||||
|
||||
api.ceilometer.meter_list(IsA(http.HttpRequest))\
|
||||
.AndReturn(self.testdata.meters.list())
|
||||
api.ceilometer.statistic_list(IsA(http.HttpRequest),
|
||||
'memory',
|
||||
period=IsA(int),
|
||||
query=IsA(list)).MultipleTimes()\
|
||||
.AndReturn(self.testdata.statistics.list())
|
||||
api.keystone.tenant_list(IsA(http.HttpRequest),
|
||||
domain=None,
|
||||
paginate=False) \
|
||||
.AndReturn([self.tenants.list(), False])
|
||||
|
||||
ceilometerclient = self.stub_ceilometerclient()
|
||||
ceilometerclient.statistics = self.mox.CreateMockAnything()
|
||||
# check that list is called twice for one resource and 2 meters
|
||||
ceilometerclient.statistics.list(meter_name="memory",
|
||||
period=IsA(int), q=IsA(list)).\
|
||||
MultipleTimes().\
|
||||
AndReturn(statistics)
|
||||
.AndReturn([self.testdata.tenants.list(), False])
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
@ -93,22 +108,21 @@ class MeteringViewTests(test.APITestCase, test.BaseAdminViewTests):
|
||||
self._verify_series(res._container[0], 4.55, '2012-12-21T11:00:55',
|
||||
expected_names)
|
||||
|
||||
@test.create_stubs({api.keystone: ('tenant_list',)})
|
||||
@test.create_stubs({api.keystone: ('tenant_list',),
|
||||
api.ceilometer: ('meter_list',
|
||||
'statistic_list',
|
||||
), })
|
||||
def test_stats_for_line_chart_attr_max(self):
|
||||
statistics = self.statistics.list()
|
||||
|
||||
api.ceilometer.meter_list(IsA(http.HttpRequest))\
|
||||
.AndReturn(self.testdata.meters.list())
|
||||
api.ceilometer.statistic_list(IsA(http.HttpRequest),
|
||||
'memory', period=IsA(int),
|
||||
query=IsA(list))\
|
||||
.MultipleTimes().AndReturn(self.testdata.statistics.list())
|
||||
api.keystone.tenant_list(IsA(http.HttpRequest),
|
||||
domain=None,
|
||||
paginate=False) \
|
||||
.AndReturn([self.tenants.list(), False])
|
||||
|
||||
ceilometerclient = self.stub_ceilometerclient()
|
||||
ceilometerclient.statistics = self.mox.CreateMockAnything()
|
||||
# check that list is called twice for one resource and 2 meters
|
||||
ceilometerclient.statistics.list(meter_name="memory",
|
||||
period=IsA(int), q=IsA(list)).\
|
||||
MultipleTimes().\
|
||||
AndReturn(statistics)
|
||||
.AndReturn([self.testdata.tenants.list(), False])
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
@ -122,131 +136,6 @@ class MeteringViewTests(test.APITestCase, test.BaseAdminViewTests):
|
||||
expected_names = ['test_tenant',
|
||||
'disabled_tenant',
|
||||
u'\u4e91\u89c4\u5219']
|
||||
|
||||
self._verify_series(res._container[0], 9.0, '2012-12-21T11:00:55',
|
||||
expected_names)
|
||||
|
||||
def test_stats_for_line_chart_no_group_by(self):
|
||||
resources = self.resources.list()
|
||||
statistics = self.statistics.list()
|
||||
|
||||
ceilometerclient = self.stub_ceilometerclient()
|
||||
ceilometerclient.resources = self.mox.CreateMockAnything()
|
||||
ceilometerclient.resources.list(q=[]).AndReturn(resources)
|
||||
|
||||
ceilometerclient.statistics = self.mox.CreateMockAnything()
|
||||
ceilometerclient.statistics.list(meter_name="storage.objects",
|
||||
period=IsA(int), q=IsA(list)).\
|
||||
MultipleTimes().\
|
||||
AndReturn(statistics)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
# getting all resources and with statistics, I have only
|
||||
# 'storage.objects' defined in test data
|
||||
res = self.client.get(
|
||||
reverse('horizon:admin:metering:samples') +
|
||||
"?meter=storage.objects&stats_attr=avg&date_options=7")
|
||||
|
||||
self.assertEqual(res._headers['content-type'],
|
||||
('Content-Type', 'application/json'))
|
||||
expected_names = ['fake_resource_id',
|
||||
'fake_resource_id2']
|
||||
self._verify_series(res._container[0], 4.55, '2012-12-21T11:00:55',
|
||||
expected_names)
|
||||
|
||||
@test.create_stubs({api.keystone: ('tenant_list',)})
|
||||
def test_report(self):
|
||||
meters = self.meters.list()
|
||||
ceilometerclient = self.stub_ceilometerclient()
|
||||
ceilometerclient.meters = self.mox.CreateMockAnything()
|
||||
ceilometerclient.meters.list(None).AndReturn(meters)
|
||||
|
||||
api.keystone.tenant_list(IsA(http.HttpRequest),
|
||||
domain=None,
|
||||
paginate=False). \
|
||||
MultipleTimes()\
|
||||
.AndReturn([self.tenants.list(), False])
|
||||
|
||||
statistics = self.statistics.list()
|
||||
ceilometerclient = self.stub_ceilometerclient()
|
||||
ceilometerclient.statistics = self.mox.CreateMockAnything()
|
||||
|
||||
ceilometerclient.statistics.list(meter_name="instance",
|
||||
period=IsA(int), q=IsA(list)).\
|
||||
MultipleTimes().\
|
||||
AndReturn(statistics)
|
||||
ceilometerclient.statistics.list(meter_name="disk.read.bytes",
|
||||
period=IsA(int), q=IsA(list)).\
|
||||
MultipleTimes().\
|
||||
AndReturn(statistics)
|
||||
ceilometerclient.statistics.list(meter_name="disk.write.bytes",
|
||||
period=IsA(int), q=IsA(list)).\
|
||||
MultipleTimes().\
|
||||
AndReturn(statistics)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
# generate report with mock data
|
||||
res = self.client.post(reverse('horizon:admin:metering:report'),
|
||||
data={"date_options": "7"})
|
||||
|
||||
self.assertTemplateUsed(res, 'admin/metering/report.html')
|
||||
|
||||
|
||||
class MeteringStatsTabTests(test.APITestCase):
|
||||
|
||||
@test.create_stubs({api.nova: ('flavor_list',),
|
||||
})
|
||||
def test_stats_hover_hints(self):
|
||||
|
||||
class Struct(object):
|
||||
def __init__(self, d):
|
||||
self.__dict__.update(d)
|
||||
|
||||
def _get_link(meter):
|
||||
link = ('http://localhost:8777/v2/meters/%s?'
|
||||
'q.field=resource_id&q.value=ignored')
|
||||
return dict(href=link % meter, rel=meter)
|
||||
|
||||
flavors = ['m1.tiny', 'm1.massive', 'm1.secret']
|
||||
resources = [
|
||||
Struct(dict(resource_id=uuid.uuid4(),
|
||||
project_id='fake_project_id',
|
||||
user_id='fake_user_id',
|
||||
timestamp='2013-10-22T12:42:37',
|
||||
metadata=dict(ramdisk_id='fake_image_id'),
|
||||
links=[_get_link('instance:%s' % f),
|
||||
_get_link('instance'),
|
||||
_get_link('cpu')])) for f in flavors
|
||||
]
|
||||
request = self.mox.CreateMock(http.HttpRequest)
|
||||
api.nova.flavor_list(request, None).AndReturn(self.flavors.list())
|
||||
|
||||
ceilometerclient = self.stub_ceilometerclient()
|
||||
|
||||
meters = []
|
||||
for r in resources:
|
||||
for link in r.links:
|
||||
meters.append(Struct(dict(resource_id=r.resource_id,
|
||||
project_id=r.project_id,
|
||||
user_id=r.user_id,
|
||||
timestamp=r.timestamp,
|
||||
name=link['rel'])))
|
||||
ceilometerclient.meters = self.mox.CreateMockAnything()
|
||||
ceilometerclient.meters.list(None).AndReturn(meters)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
tab = tabs.GlobalStatsTab(None)
|
||||
context_data = tab.get_context_data(request)
|
||||
|
||||
self.assertTrue('nova_meters' in context_data)
|
||||
meter_hints = {}
|
||||
for d in context_data['nova_meters']:
|
||||
meter_hints[d.name] = d.description
|
||||
|
||||
expected_meters = ['instance:%s' % f for f in flavors]
|
||||
expected_meters.extend(['instance', 'cpu'])
|
||||
for meter in expected_meters:
|
||||
self.assertTrue(meter in meter_hints)
|
||||
self.assertNotEqual(meter_hints[meter], '')
|
||||
|
@ -18,6 +18,6 @@ from openstack_dashboard.dashboards.admin.metering import views
|
||||
urlpatterns = patterns(
|
||||
'openstack_dashboard.dashboards.admin.metering.views',
|
||||
url(r'^$', views.IndexView.as_view(), name='index'),
|
||||
url(r'^create/$', views.CreateUsageReport.as_view(), name='create'),
|
||||
url(r'^samples$', views.SamplesView.as_view(), name='samples'),
|
||||
url(r'^report$', views.ReportView.as_view(), name='report'),
|
||||
url(r'^report/csv$', views.CsvReportView.as_view(), name='csvreport'))
|
||||
|
@ -10,29 +10,25 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from datetime import datetime # noqa
|
||||
from datetime import timedelta # noqa
|
||||
|
||||
import json
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.urlresolvers import reverse_lazy
|
||||
from django.http import HttpResponse # noqa
|
||||
from django.utils.datastructures import SortedDict
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
import django.views
|
||||
|
||||
from horizon import exceptions
|
||||
from horizon import tables
|
||||
from horizon import forms
|
||||
from horizon import tabs
|
||||
from horizon.utils import csvbase
|
||||
|
||||
from openstack_dashboard import api
|
||||
from openstack_dashboard.api import ceilometer
|
||||
|
||||
from openstack_dashboard.dashboards.admin.metering import tables as \
|
||||
metering_tables
|
||||
from openstack_dashboard.dashboards.admin.metering import forms as \
|
||||
metering_forms
|
||||
from openstack_dashboard.dashboards.admin.metering import tabs as \
|
||||
metering_tabs
|
||||
from openstack_dashboard.utils import metering as utils_metering
|
||||
|
||||
|
||||
class IndexView(tabs.TabbedTableView):
|
||||
@ -40,6 +36,12 @@ class IndexView(tabs.TabbedTableView):
|
||||
template_name = 'admin/metering/index.html'
|
||||
|
||||
|
||||
class CreateUsageReport(forms.ModalFormView):
|
||||
form_class = metering_forms.UsageReportForm
|
||||
template_name = 'admin/metering/daily.html'
|
||||
success_url = reverse_lazy('horizon:admin:metering:index')
|
||||
|
||||
|
||||
class SamplesView(django.views.generic.TemplateView):
|
||||
template_name = "admin/metering/samples.csv"
|
||||
|
||||
@ -76,19 +78,29 @@ class SamplesView(django.views.generic.TemplateView):
|
||||
stats_attr = request.GET.get('stats_attr', 'avg')
|
||||
group_by = request.GET.get('group_by', None)
|
||||
|
||||
resources, unit = query_data(request,
|
||||
try:
|
||||
date_from, date_to = utils_metering.calc_date_args(date_from,
|
||||
date_to,
|
||||
date_options)
|
||||
except Exception:
|
||||
exceptions.handle(self.request, _('Dates cannot be recognized.'))
|
||||
|
||||
if group_by == 'project':
|
||||
query = utils_metering.ProjectAggregatesQuery(request,
|
||||
date_from,
|
||||
date_to,
|
||||
date_options,
|
||||
group_by,
|
||||
meter)
|
||||
3600 * 24)
|
||||
else:
|
||||
query = utils_metering.MeterQuery(request, date_from,
|
||||
date_to, 3600 * 24)
|
||||
|
||||
resources, unit = query.query(meter_name)
|
||||
resource_name = 'id' if group_by == "project" else 'resource_id'
|
||||
series = self._series_for_meter(resources,
|
||||
resource_name,
|
||||
meter_name,
|
||||
stats_attr,
|
||||
unit)
|
||||
|
||||
ret = {}
|
||||
ret['series'] = series
|
||||
ret['settings'] = {}
|
||||
@ -97,39 +109,6 @@ class SamplesView(django.views.generic.TemplateView):
|
||||
content_type='application/json')
|
||||
|
||||
|
||||
class ReportView(tables.MultiTableView):
|
||||
template_name = 'admin/metering/report.html'
|
||||
|
||||
def get_tables(self):
|
||||
if self._tables:
|
||||
return self._tables
|
||||
project_data = load_report_data(self.request)
|
||||
table_instances = []
|
||||
limit = int(self.request.POST.get('limit', '1000'))
|
||||
for project in project_data.keys():
|
||||
table = metering_tables.UsageTable(self.request,
|
||||
data=project_data[project],
|
||||
kwargs=self.kwargs.copy())
|
||||
table.title = project
|
||||
t = (table.name, table)
|
||||
table_instances.append(t)
|
||||
if len(table_instances) == limit:
|
||||
break
|
||||
self._tables = SortedDict(table_instances)
|
||||
self.project_data = project_data
|
||||
return self._tables
|
||||
|
||||
def handle_table(self, table):
|
||||
name = table.name
|
||||
handled = self._tables[name].maybe_handle()
|
||||
return handled
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = {'tables': self.get_tables().values()}
|
||||
context['csv_url'] = reverse('horizon:admin:metering:csvreport')
|
||||
return context
|
||||
|
||||
|
||||
class CsvReportView(django.views.generic.View):
|
||||
def get(self, request, **response_kwargs):
|
||||
render_class = ReportCsvRenderer
|
||||
@ -160,150 +139,6 @@ class ReportCsvRenderer(csvbase.BaseCsvResponse):
|
||||
u["value"])
|
||||
|
||||
|
||||
def _calc_period(date_from, date_to):
|
||||
if date_from and date_to:
|
||||
if date_to < date_from:
|
||||
# TODO(lsmola) propagate the Value error through Horizon
|
||||
# handler to the client with verbose message.
|
||||
raise ValueError("Date to must be bigger than date "
|
||||
"from.")
|
||||
# get the time delta in seconds
|
||||
delta = date_to - date_from
|
||||
if delta.days <= 0:
|
||||
# it's one day
|
||||
delta_in_seconds = 3600 * 24
|
||||
else:
|
||||
delta_in_seconds = delta.days * 24 * 3600 + delta.seconds
|
||||
# Lets always show 400 samples in the chart. Know that it is
|
||||
# maximum amount of samples and it can be lower.
|
||||
number_of_samples = 400
|
||||
period = delta_in_seconds / number_of_samples
|
||||
else:
|
||||
# If some date is missing, just set static window to one day.
|
||||
period = 3600 * 24
|
||||
return period
|
||||
|
||||
|
||||
def _calc_date_args(date_from, date_to, date_options):
|
||||
# TODO(lsmola) all timestamps should probably work with
|
||||
# current timezone. And also show the current timezone in chart.
|
||||
if (date_options == "other"):
|
||||
try:
|
||||
if date_from:
|
||||
date_from = datetime.strptime(date_from,
|
||||
"%Y-%m-%d")
|
||||
else:
|
||||
# TODO(lsmola) there should be probably the date
|
||||
# of the first sample as default, so it correctly
|
||||
# counts the time window. Though I need ordering
|
||||
# and limit of samples to obtain that.
|
||||
pass
|
||||
if date_to:
|
||||
date_to = datetime.strptime(date_to,
|
||||
"%Y-%m-%d")
|
||||
# It return beginning of the day, I want the and of
|
||||
# the day, so i will add one day without a second.
|
||||
date_to = (date_to + timedelta(days=1) -
|
||||
timedelta(seconds=1))
|
||||
else:
|
||||
date_to = datetime.now()
|
||||
except Exception:
|
||||
raise ValueError("The dates are not "
|
||||
"recognized.")
|
||||
else:
|
||||
try:
|
||||
date_from = datetime.now() - timedelta(days=int(date_options))
|
||||
date_to = datetime.now()
|
||||
except Exception:
|
||||
raise ValueError("The time delta must be an "
|
||||
"integer representing days.")
|
||||
return date_from, date_to
|
||||
|
||||
|
||||
def query_data(request,
|
||||
date_from,
|
||||
date_to,
|
||||
date_options,
|
||||
group_by,
|
||||
meter,
|
||||
period=None,
|
||||
additional_query=None):
|
||||
date_from, date_to = _calc_date_args(date_from,
|
||||
date_to,
|
||||
date_options)
|
||||
if not period:
|
||||
period = _calc_period(date_from, date_to)
|
||||
if additional_query is None:
|
||||
additional_query = []
|
||||
if date_from:
|
||||
additional_query += [{'field': 'timestamp',
|
||||
'op': 'ge',
|
||||
'value': date_from}]
|
||||
if date_to:
|
||||
additional_query += [{'field': 'timestamp',
|
||||
'op': 'le',
|
||||
'value': date_to}]
|
||||
|
||||
# TODO(lsmola) replace this by logic implemented in I1 in bugs
|
||||
# 1226479 and 1226482, this is just a quick fix for RC1
|
||||
try:
|
||||
meter_list = [m for m in ceilometer.meter_list(request)
|
||||
if m.name == meter]
|
||||
unit = meter_list[0].unit
|
||||
except Exception:
|
||||
unit = ""
|
||||
if group_by == "project":
|
||||
try:
|
||||
tenants, more = api.keystone.tenant_list(
|
||||
request,
|
||||
domain=None,
|
||||
paginate=False)
|
||||
except Exception:
|
||||
tenants = []
|
||||
exceptions.handle(request,
|
||||
_('Unable to retrieve project list.'))
|
||||
queries = {}
|
||||
for tenant in tenants:
|
||||
tenant_query = [{
|
||||
"field": "project_id",
|
||||
"op": "eq",
|
||||
"value": tenant.id}]
|
||||
|
||||
queries[tenant.name] = tenant_query
|
||||
|
||||
ceilometer_usage = ceilometer.CeilometerUsage(request)
|
||||
resources = ceilometer_usage.resource_aggregates_with_statistics(
|
||||
queries, [meter], period=period, stats_attr=None,
|
||||
additional_query=additional_query)
|
||||
|
||||
else:
|
||||
query = []
|
||||
|
||||
def filter_by_meter_name(resource):
|
||||
"""Function for filtering of the list of resources.
|
||||
|
||||
Will pick the right resources according to currently selected
|
||||
meter.
|
||||
"""
|
||||
for link in resource.links:
|
||||
if link['rel'] == meter:
|
||||
# If resource has the currently chosen meter.
|
||||
return True
|
||||
return False
|
||||
|
||||
ceilometer_usage = ceilometer.CeilometerUsage(request)
|
||||
try:
|
||||
resources = ceilometer_usage.resources_with_statistics(
|
||||
query, [meter], period=period, stats_attr=None,
|
||||
additional_query=additional_query,
|
||||
filter_func=filter_by_meter_name)
|
||||
except Exception:
|
||||
resources = []
|
||||
exceptions.handle(request,
|
||||
_('Unable to retrieve statistics.'))
|
||||
return resources, unit
|
||||
|
||||
|
||||
def load_report_data(request):
|
||||
meters = ceilometer.Meters(request)
|
||||
services = {
|
||||
@ -318,21 +153,27 @@ def load_report_data(request):
|
||||
date_options = request.GET.get('date_options', 7)
|
||||
date_from = request.GET.get('date_from')
|
||||
date_to = request.GET.get('date_to')
|
||||
try:
|
||||
date_from, date_to = utils_metering.calc_date_args(date_from,
|
||||
date_to,
|
||||
date_options)
|
||||
except Exception:
|
||||
exceptions.handle(request, _('Dates cannot be recognised.'))
|
||||
try:
|
||||
project_aggregates = utils_metering.ProjectAggregatesQuery(request,
|
||||
date_from,
|
||||
date_to,
|
||||
3600 * 24)
|
||||
except Exception:
|
||||
exceptions.handle(request,
|
||||
_('Unable to retrieve project list.'))
|
||||
for meter in meters._cached_meters.values():
|
||||
service = None
|
||||
for name, m_list in services.items():
|
||||
if meter in m_list:
|
||||
service = name
|
||||
break
|
||||
# show detailed samples
|
||||
# samples = ceilometer.sample_list(request, meter.name)
|
||||
res, unit = query_data(request,
|
||||
date_from,
|
||||
date_to,
|
||||
date_options,
|
||||
"project",
|
||||
meter.name,
|
||||
3600 * 24)
|
||||
res, unit = project_aggregates.query(meter.name)
|
||||
for re in res:
|
||||
values = re.get_meter(meter.name.replace(".", "_"))
|
||||
if values:
|
||||
|
152
openstack_dashboard/utils/metering.py
Normal file
152
openstack_dashboard/utils/metering.py
Normal file
@ -0,0 +1,152 @@
|
||||
# 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.
|
||||
|
||||
import datetime
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from openstack_dashboard import api
|
||||
|
||||
|
||||
def calc_period(date_from, date_to):
|
||||
if date_from and date_to:
|
||||
if date_to < date_from:
|
||||
# TODO(lsmola) propagate the Value error through Horizon
|
||||
# handler to the client with verbose message.
|
||||
raise ValueError(_("To date to must be greater than From date."))
|
||||
|
||||
# get the time delta in seconds
|
||||
delta = date_to - date_from
|
||||
if delta.days <= 0:
|
||||
# it's one day
|
||||
delta_in_seconds = 3600 * 24
|
||||
else:
|
||||
delta_in_seconds = delta.days * 24 * 3600 + delta.seconds
|
||||
# Lets always show 400 samples in the chart. Know that it is
|
||||
# maximum amount of samples and it can be lower.
|
||||
number_of_samples = 400
|
||||
period = delta_in_seconds / number_of_samples
|
||||
else:
|
||||
# If some date is missing, just set static window to one day.
|
||||
period = 3600 * 24
|
||||
return period
|
||||
|
||||
|
||||
def calc_date_args(date_from, date_to, date_options):
|
||||
if date_options == "other":
|
||||
if date_from and not isinstance(date_from, datetime.date):
|
||||
try:
|
||||
date_from = datetime.datetime.strptime(date_from,
|
||||
"%Y-%m-%d")
|
||||
except Exception:
|
||||
raise ValueError(_("From-date is not recognized"))
|
||||
|
||||
if date_to:
|
||||
if not isinstance(date_to, datetime.date):
|
||||
try:
|
||||
date_to = datetime.datetime.strptime(date_to,
|
||||
"%Y-%m-%d")
|
||||
except Exception:
|
||||
raise ValueError(_("To-date is not recognized"))
|
||||
else:
|
||||
date_to = timezone.now()
|
||||
else:
|
||||
try:
|
||||
date_to = timezone.now()
|
||||
date_from = date_to - datetime.timedelta(days=int(date_options))
|
||||
except Exception:
|
||||
raise ValueError(_("The time delta must be an "
|
||||
"integer representing days."))
|
||||
return date_from, date_to
|
||||
|
||||
|
||||
class ProjectAggregatesQuery(object):
|
||||
def __init__(self, request, date_from, date_to,
|
||||
period=None, additional_query=[]):
|
||||
if not period:
|
||||
period = calc_period(date_from, date_to)
|
||||
if date_from:
|
||||
additional_query.append({'field': 'timestamp',
|
||||
'op': 'ge',
|
||||
'value': date_from})
|
||||
if date_to:
|
||||
additional_query.append({'field': 'timestamp',
|
||||
'op': 'le',
|
||||
'value': date_to})
|
||||
|
||||
self.request = request
|
||||
self.period = period
|
||||
self.additional_query = additional_query
|
||||
tenants, more = api.keystone.tenant_list(request,
|
||||
domain=None,
|
||||
paginate=False)
|
||||
self.queries = {}
|
||||
|
||||
for tenant in tenants:
|
||||
tenant_query = [{
|
||||
"field": "project_id",
|
||||
"op": "eq",
|
||||
"value": tenant.id}]
|
||||
|
||||
self.queries[tenant.name] = tenant_query
|
||||
|
||||
def query(self, meter):
|
||||
meter_list = [m for m in api.ceilometer.meter_list(self.request)
|
||||
if m.name == meter]
|
||||
unit = ""
|
||||
if len(meter_list) > 0:
|
||||
unit = meter_list[0].unit
|
||||
ceilometer_usage = api.ceilometer.CeilometerUsage(self.request)
|
||||
resources = ceilometer_usage.resource_aggregates_with_statistics(
|
||||
self.queries, [meter], period=self.period,
|
||||
stats_attr=None,
|
||||
additional_query=self.additional_query)
|
||||
return resources, unit
|
||||
|
||||
|
||||
class MeterQuery(ProjectAggregatesQuery):
|
||||
def __init__(self, *args, **kwargs):
|
||||
# pop filterfunc and add it later to self.
|
||||
filterfunc = kwargs.pop('filterfunc', None)
|
||||
super(MeterQuery, self).__init__(*args, **kwargs)
|
||||
self.filterfunc = filterfunc
|
||||
|
||||
def query(self, meter):
|
||||
def filter_by_meter_name(resource):
|
||||
"""Function for filtering of the list of resources.
|
||||
|
||||
Will pick the right resources according to currently selected
|
||||
meter.
|
||||
"""
|
||||
for link in resource.links:
|
||||
if link['rel'] == meter:
|
||||
# If resource has the currently chosen meter.
|
||||
return True
|
||||
return False
|
||||
|
||||
meter_list = [m for m in api.ceilometer.meter_list(self.request)
|
||||
if m.name == meter]
|
||||
|
||||
unit = ""
|
||||
if len(meter_list) > 0:
|
||||
unit = meter_list[0].unit
|
||||
|
||||
ceilometer_usage = api.ceilometer.CeilometerUsage(self.request)
|
||||
resources = ceilometer_usage.resources_with_statistics(
|
||||
self.queries, [meter],
|
||||
period=self.period,
|
||||
stats_attr=None,
|
||||
additional_query=self.additional_query,
|
||||
filter_func=filter_by_meter_name)
|
||||
|
||||
return resources, unit
|
Loading…
Reference in New Issue
Block a user