Refactor distil api for distil v2

* Support displaying balance of credits
* Fewer API calls
* Returns data across all regions instead of just the current one
* Support viewing details for previous invoices
* Highlight unpaid invoices

Change-Id: Ic0f950948c83e76d760123046e8ffbd12dd632dd
This commit is contained in:
Amelia Cordwell 2017-07-05 15:22:21 +12:00 committed by Feilong Wang
parent c66fdf932d
commit d7282f9d15
14 changed files with 972 additions and 1033 deletions

View File

@ -1,316 +0,0 @@
# Copyright (c) 2014 Catalyst IT Ltd.
#
# 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 collections
import datetime
import logging
import math
import time
from django.conf import settings
import eventlet
from openstack_dashboard.api import base
LOG = logging.getLogger(__name__)
BILLITEM = collections.namedtuple('BillItem',
['id', 'resource', 'count', 'cost'])
EMPTY_BREAKDOWN = [BILLITEM(id=1, resource='N/A', count=0, cost=0)]
RES_NAME_MAPPING = {'Virtual Machine': 'Compute',
'Volume': 'Block Storage'}
KNOWN_RESOURCE_TYPE = ['Compute', 'Block Storage', 'Network', 'Router',
'Image', 'Floating IP', 'Object Storage', 'VPN',
'Inbound International Traffic',
'Outbound International Traffic',
'Inbound National Traffic',
'Outbound National Traffic']
SRV_RES_MAPPING = {'m1.tiny': 'Compute',
'm1.small': 'Compute',
'm1.mini': 'Compute',
'm1.medium': 'Compute',
'c1.small': 'Compute',
'm1.large': 'Compute',
'm1.xlarge': 'Compute',
'c1.large': 'Compute',
'c1.xlarge': 'Compute',
'c1.xxlarge': 'Compute',
'm1.2xlarge': 'Compute',
'c1.c1r1': 'Compute',
'c1.c1r2': 'Compute',
'c1.c1r4': 'Compute',
'c1.c2r1': 'Compute',
'c1.c2r2': 'Compute',
'c1.c2r4': 'Compute',
'c1.c2r8': 'Compute',
'c1.c2r16': 'Compute',
'c1.c4r2': 'Compute',
'c1.c4r4': 'Compute',
'c1.c4r8': 'Compute',
'c1.c4r16': 'Compute',
'c1.c4r32': 'Compute',
'c1.c8r4': 'Compute',
'c1.c8r8': 'Compute',
'c1.c8r16': 'Compute',
'c1.c8r32': 'Compute',
'b1.standard': 'Block Storage',
'o1.standard': 'Object Storage',
'n1.ipv4': 'Floating IP',
'n1.network': 'Network',
'n1.router': 'Router',
'n1.vpn': 'VPN',
'n1.international-in': 'Inbound International Traffic',
'n1.international-out': 'Outbound International Traffic',
'n1.national-in': 'Inbound National Traffic',
'n1.national-out': 'Outbound National Traffic'}
TRAFFIC_MAPPING = {'n1.international-in': 'Inbound International Traffic',
'n1.international-out': 'Outbound International Traffic',
'n1.national-in': 'Inbound National Traffic',
'n1.national-out': 'Outbound National Traffic'}
CACHE = {}
def distilclient(request):
try:
try:
from distilclient import client
except Exception:
from distil.client import client
auth_url = base.url_for(request, service_type='identity')
distil_url = base.url_for(request, service_type='rating')
insecure = getattr(settings, 'OPENSTACK_SSL_NO_VERIFY', False)
cacert = getattr(settings, 'OPENSTACK_SSL_CACERT', None)
distil = client.Client(distil_url=distil_url,
os_auth_token=request.user.token.id,
os_tenant_id=request.user.tenant_id,
os_auth_url=auth_url,
os_region_name=request.user.services_region,
insecure=insecure,
os_cacert=cacert)
distil.request = request
except Exception as e:
LOG.error(e)
return
return distil
def _get_month_cost(distil_client, tenant_id, start_str, end_str,
history_cost, i):
today = datetime.datetime.today()
start = datetime.datetime.strptime(start_str, '%Y-%m-%dT%H:%M:%S')
cache_key = (distil_client.endpoint + '_' + tenant_id + '_' +
start_str + '_' + end_str)
if cache_key in CACHE:
history_cost[i] = CACHE[cache_key]
return
month_cost = distil_client.get_rated([tenant_id], start_str,
end_str)['usage']
resource_cost = collections.OrderedDict()
prices = {}
cost_details = collections.defaultdict(list)
for res in KNOWN_RESOURCE_TYPE:
cost_details[res] = []
for res_id, details in month_cost['resources'].items():
resource_type = details['type']
for s in details['services']:
if resource_type not in prices:
try:
prices[resource_type] = float(s.get('rate', 0))
except Exception as e:
LOG.error('Failed to get rate for %s since %s' % (s, e))
# Only collect service details for current month, we may support
# the details for history in the future.
if ((start.year == today.year and start.month == today.month) or
s['name'] in TRAFFIC_MAPPING):
try:
s_copy = s.copy()
s_copy['volume'] = round(float(s_copy['volume']), 4)
s_copy['resource_id'] = res_id
cd_key = ('Image' if resource_type == 'Image' else
SRV_RES_MAPPING.get(s['name'], resource_type))
if cd_key in ('Image', 'Block Storage', 'Object Storage'):
s_copy['unit'] = 'gigabyte * hour'
# NOTE(flwang): Get the related resource info
if resource_type == 'Floating IP':
s_copy['resource'] = details['ip address']
if resource_type in ('Image', 'Object Storage Container',
'Network', 'Virtual Machine',
'Router', 'VPN', 'Volume'):
s_copy['resource'] = details['name']
cost_details.get(cd_key).append(s_copy)
except Exception as e:
LOG.error('Failed to save: %s, since %s' % (s, e))
continue
res_type = (resource_type if resource_type not in
RES_NAME_MAPPING else RES_NAME_MAPPING[resource_type])
count, cost = _calculate_count_cost(list(details['services']),
res_type)
if res_type in resource_cost:
tmp_count_cost = resource_cost[res_type]
tmp_count_cost = [tmp_count_cost[0] + count,
tmp_count_cost[1] + cost]
resource_cost[res_type] = tmp_count_cost
else:
resource_cost[res_type] = [count, cost]
# NOTE(flwang): Based on current Distil API design, it's making the
# traffic data associate with floating ip and router. So we need to
# get them out and recalculate the cost of floating ip and router.
if ['admin'] in [r.values() for r in distil_client.request.user.roles]:
_calculate_traffic_cost(cost_details, resource_cost)
breakdown = []
total_cost = 0
for resource, count_cost in resource_cost.items():
rounded_cost = round(count_cost[1], 2)
breakdown.append(BILLITEM(id=len(breakdown) + 1,
resource=resource,
count=count_cost[0],
cost=rounded_cost))
total_cost += rounded_cost
if breakdown:
if start.year == today.year and start.month == today.month:
# Only apply/show the discount for current month
end_str = today.strftime('%Y-%m-%dT%H:00:00')
history_cost[i] = _apply_discount((round(total_cost, 2),
breakdown, cost_details),
start_str,
end_str,
prices)
else:
month_cost = (round(total_cost, 2), breakdown, [])
if month_cost:
CACHE[cache_key] = month_cost
history_cost[i] = month_cost
def _calculate_count_cost(service_details, resource_type):
count = 0
cost = 0
for s in service_details:
if resource_type == 'Image' and s['name'] == 'b1.standard':
count += 1
cost += float(s['cost'])
if SRV_RES_MAPPING.get(s['name'], '') == resource_type:
count += 1
cost += float(s['cost'])
return count, cost
def _calculate_traffic_cost(cost_details, resource_cost):
for resource_type in TRAFFIC_MAPPING.values():
if resource_type in cost_details:
(count, cost) = _calculate_count_cost(cost_details[resource_type],
resource_type)
if cost > 0:
resource_cost[resource_type] = (count, cost)
def _apply_discount(cost, start_str, end_str, prices):
"""Appy discount for the usage costs
For now we only show the discount info for current month cost, because
the discount for history month has shown on customer's invoice.
"""
total_cost, breakdown, cost_details = cost
start = time.mktime(time.strptime(start_str, '%Y-%m-%dT%H:%M:%S'))
end = time.mktime(time.strptime(end_str, '%Y-%m-%dT%H:%M:%S'))
# Get the integer part of the hours
free_hours = math.floor((end - start) / 3600)
free_network_cost = round(prices.get('Network', 0.0164) * free_hours, 2)
free_router_cost = round(prices.get('Router', 0.0170) * free_hours, 2)
for item in breakdown:
if item.resource == 'Network':
free_network_cost = (item.cost if item.cost <= free_network_cost
else free_network_cost)
breakdown[item.id - 1] = item._replace(cost=(item.cost -
free_network_cost))
total_cost -= free_network_cost
if item.resource == 'Router':
free_router_cost = (item.cost if item.cost <= free_router_cost
else free_router_cost)
breakdown[item.id - 1] = item._replace(cost=(item.cost -
free_router_cost))
total_cost -= free_router_cost
return (total_cost, breakdown, cost_details)
def _calculate_start_date(today):
last_year = today.year - 1 if today.month < 12 else today.year
month = ((today.month + 1) % 12 if today.month + 1 > 12
else today.month + 1)
return datetime.datetime(last_year, month, 1)
def _calculate_end_date(start):
year = start.year + 1 if start.month + 1 > 12 else start.year
month = (start.month + 1) % 12 or 12
return datetime.datetime(year, month, 1)
def get_cost(request, distil_client=None, enable_eventlet=True):
"""Get cost for the last 1atest 12 months include current month
This function will return the latest 12 months cost and their breakdown
details, which includes current month.
"""
if enable_eventlet:
eventlet.monkey_patch()
thread_pool = eventlet.GreenPool(size=12)
history_cost = [(0, EMPTY_BREAKDOWN, []) for _ in range(12)]
distil_client = distil_client or distilclient(request)
if not distil_client:
return history_cost
today = datetime.date.today()
start = _calculate_start_date(datetime.date.today())
end = _calculate_end_date(start)
final_end = datetime.datetime(today.year, today.month + 1, 1)
try:
for i in range(12):
start_str = start.strftime("%Y-%m-%dT00:00:00")
end_str = end.strftime("%Y-%m-%dT00:00:00")
thread_pool.spawn_n(_get_month_cost,
distil_client, request.user.tenant_id,
start_str, end_str,
history_cost, i)
start = end
end = _calculate_end_date(start)
if end > final_end:
break
thread_pool.waitall()
except Exception as e:
LOG.exception('Failed to get the history cost data', e)
return history_cost

293
distil_ui/api/distil_v2.py Normal file
View File

@ -0,0 +1,293 @@
# Copyright (c) 2014 Catalyst IT Ltd.
#
# 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 collections
import datetime
import logging
import six
from django.conf import settings
from openstack_dashboard.api import base
LOG = logging.getLogger(__name__)
COMPUTE_CATEGORY = "Compute"
NETWORK_CATEGORY = "Network"
BLOCKSTORAGE_CATEGORY = "Block Storage"
OBJECTSTORAGE_CATEGORY = "Object Storage"
DISCOUNTS_CATEGORY = "Discounts"
def distilclient(request, region_id=None):
try:
from distilclient import client
auth_url = base.url_for(request, service_type='identity')
distil_url = base.url_for(request, service_type='ratingv2',
region=region_id)
insecure = getattr(settings, 'OPENSTACK_SSL_NO_VERIFY', False)
cacert = getattr(settings, 'OPENSTACK_SSL_CACERT', None)
version = getattr(settings, 'DISTIL_VERSION', '2')
distil = client.Client(distil_url=distil_url,
input_auth_token=request.user.token.id,
tenant_id=request.user.tenant_id,
auth_url=auth_url,
region_name=request.user.services_region,
insecure=insecure,
os_cacert=cacert,
version=version)
distil.request = request
except Exception as e:
LOG.error(e)
return
return distil
def _calculate_start_date(today):
last_year = today.year - 1 if today.month < 12 else today.year
month = ((today.month + 1) % 12 if today.month + 1 > 12
else today.month + 1)
return datetime.datetime(last_year, month, 1)
def _calculate_end_date(start):
year = start.year + 1 if start.month + 1 > 12 else start.year
month = (start.month + 1) % 12 or 12
return datetime.datetime(year, month, 1)
def _wash_details(current_details):
"""Apply the discount for current month quotation and merge object storage
Unfortunately, we have to put it here, though here is not the right place.
Most of the code grab from internal billing script to keep the max
consistency.
:param current_details: The original cost details merged from all regions
:return cost details after applying discount and merging object storage
"""
end = datetime.datetime.utcnow()
start = datetime.datetime.strptime('%s-%s-01T00:00:00' %
(end.year, end.month),
'%Y-%m-%dT00:00:00')
free_hours = int((end - start).total_seconds() / 3600)
network_hours = collections.defaultdict(float)
router_hours = collections.defaultdict(float)
swift_usage = collections.defaultdict(list)
washed_details = []
rate_router = 0
rate_network = 0
for u in current_details["details"]:
# FIXME(flwang): 8 is the magic number here, we need a better way
# to get the region name.
region = u["product"].split(".")[0]
if u['product'].endswith('n1.network'):
network_hours[region] += u['quantity']
rate_network = u['rate']
if u['product'].endswith('n1.router'):
router_hours[region] += u['quantity']
rate_router = u['rate']
if u['product'].endswith('o1.standard'):
swift_usage[u['resource_id']].append(u)
else:
washed_details.append(u)
free_network_hours_left = free_hours
for region, hours in six.iteritems(network_hours):
free_network_hours = (hours if hours <= free_network_hours_left
else free_network_hours_left)
if not free_network_hours:
break
line_name = 'Free Network Tier in %s' % region
washed_details.append({'product': region + '.n1.network',
'resource_name': line_name,
'quantity': free_network_hours,
'resource_id': '',
'unit': 'hour', 'rate': -rate_network,
'cost': round(free_network_hours *
-rate_network, 2)})
free_network_hours_left -= free_network_hours
free_router_hours_left = free_hours
for region, hours in six.iteritems(router_hours):
free_router_hours = (hours if hours <= free_router_hours_left
else free_router_hours_left)
if not free_router_hours:
break
line_name = 'Free Router Tier in %s' % region
washed_details.append({'product': region + '.n1.router',
'resource_name': line_name,
'quantity': free_router_hours,
'resource_id': '',
'unit': 'hour', 'rate': -rate_router,
'cost': round(free_router_hours *
-rate_router, 2)})
free_router_hours_left -= free_router_hours
region_count = 0
for container, container_usage in swift_usage.items():
region_count = len(container_usage)
if (len(container_usage) > 0 and
container_usage[0]['product'].endswith('o1.standard')):
# NOTE(flwang): Find the biggest size
container_usage[0]['product'] = "NZ.o1.standard"
container_usage[0]['quantity'] = max([u['quantity']
for u in container_usage])
washed_details.append(container_usage[0])
current_details["details"] = washed_details
# NOTE(flwang): Currently, the breakdown will accumulate all the object
# storage cost, so we need to deduce the duplicated part.
object_cost = current_details["breakdown"].get(OBJECTSTORAGE_CATEGORY, 0)
dup_object_cost = (region_count - 1) * (object_cost / region_count)
current_details["total_cost"] = (current_details["total_cost"] -
dup_object_cost)
return current_details
def _parse_invoice(invoice):
LOG.debug("Start to get invoices.")
parsed = {"total_cost": 0, "breakdown": {}, "details": []}
parsed["total_cost"] += invoice["total_cost"]
breakdown = parsed["breakdown"]
details = parsed["details"]
for category, services in invoice['details'].items():
if category != DISCOUNTS_CATEGORY:
breakdown[category] = services["total_cost"]
for product in services["breakdown"]:
for order_line in services["breakdown"][product]:
order_line["product"] = product
details.append(order_line)
LOG.debug("Got quotations successfully.")
return parsed
def _parse_quotation(quotation, merged_quotations, region=None):
parsed = merged_quotations
parsed["total_cost"] += quotation["total_cost"]
breakdown = parsed["breakdown"]
details = parsed["details"]
for category, services in quotation['details'].items():
if category in breakdown:
breakdown[category] += services["total_cost"]
else:
breakdown[category] = services["total_cost"]
for product in services["breakdown"]:
for order_line in services["breakdown"][product]:
order_line["product"] = product
details.append(order_line)
return parsed
def _get_quotations(request):
LOG.debug("Start to get quotations from all regions.")
today_date = datetime.date.today().strftime("%Y-%m-%d")
regions = request.user.available_services_regions
merged_quotations = {"total_cost": 0, "breakdown": {}, "details": [],
"date": today_date, "status": None}
for region in regions:
region_client = distilclient(request, region_id=region)
resp = region_client.quotations.list(detailed=True)
quotation = resp['quotations'][today_date]
merged_quotations = _parse_quotation(quotation, merged_quotations,
region)
merged_quotations = _wash_details(merged_quotations)
LOG.debug("Got quotations from all regions successfully.")
return merged_quotations
def get_cost(request, distil_client=None):
"""Get cost for the 1atest 12 months include current month
This function will return the latest 12 months cost and the breakdown
details for the each month.
:param request: Horizon request object
:param distil_client: Client object of Distilclient
:return list of cost for last 12 months
"""
# 1. Process invoices
today = datetime.date.today()
start = _calculate_start_date(datetime.date.today())
# NOTE(flwang): It's OK to get invoice using the 1st day of curent month
# as the "end" date.
end = datetime.datetime(today.year, today.month, 1)
cost = [{"date": None, "total_cost": 0, "paid": False, "breakdown": {},
"details": {}}]
temp_end = end
for i in range(11):
last_day = temp_end - datetime.timedelta(seconds=1)
temp_end = datetime.datetime(last_day.year, last_day.month, 1)
cost.insert(0, {"date": last_day.strftime("%Y-%m-%d"), "total_cost": 0,
"paid": False, "breakdown": {}, "details": {}})
if temp_end < start:
break
distil_client = distil_client or distilclient(request)
if not distil_client:
return cost
# FIXME(flwang): Get the last 11 invoices. If "today" is the early of month
# then it's possible that the invoice hasn't been created. And there is no
# way to see it based on current design of Distil API.
invoices = distil_client.invoices.list(start, end,
detailed=True)['invoices']
ordered_invoices = collections.OrderedDict(sorted(invoices.items(),
key=lambda t: t[0]))
# NOTE(flwang): The length of invoices dict could be less than 11 based on
# above comments.
for i in range(len(cost)):
month_cost = ordered_invoices.get(cost[i]['date'])
if not month_cost:
continue
cost[i]["total_cost"] = month_cost["total_cost"]
cost[i]["status"] = month_cost.get("status", None)
parsed = _parse_invoice(month_cost)
cost[i]["breakdown"] = parsed["breakdown"]
cost[i]["details"] = parsed["details"]
# 2. Process quotations from all regions
# NOTE(flwang): The quotations from all regions is always the last one of
# the cost list.
cost[-1] = _get_quotations(request)
return cost
def get_credits(request, distil_client=None):
"""Get balance of customer's credit
For now, it only supports credits like trail, development grant or
education grant. In the future, we will add supports for term discount if
it applys.
:param request: Horizon request object
:param distil_client: Client object of Distilclient
:return dict of credits
"""
distil_client = distil_client or distilclient(request)
if not distil_client:
return {}
return distil_client.credits.list()

View File

@ -1,104 +0,0 @@
# 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 __future__ import division
import datetime
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from horizon import forms
from horizon import messages
class BaseBilling(object):
def __init__(self, request, project_id=None):
self.project_id = project_id or request.user.tenant_id
self.request = request
self.billing_list = []
@property
def today(self):
return timezone.now()
@staticmethod
def get_start(year, month, day):
start = datetime.datetime(year, month, day, 0, 0, 0)
return timezone.make_aware(start, timezone.utc)
@staticmethod
def get_end(year, month, day):
end = datetime.datetime(year, month, day, 23, 59, 59)
return timezone.make_aware(end, timezone.utc)
def get_date_range(self):
if not hasattr(self, "start") or not hasattr(self, "end"):
args_start = (self.today.year, self.today.month, 1)
args_end = (self.today.year, self.today.month, self.today.day)
form = self.get_form()
if form.is_valid():
start = form.cleaned_data['start']
end = form.cleaned_data['end']
args_start = (start.year,
start.month,
start.day)
args_end = (end.year,
end.month,
end.day)
elif form.is_bound:
messages.error(self.request,
_("Invalid date format: "
"Using today as default."))
self.start = self.get_start(*args_start)
self.end = self.get_end(*args_end)
return self.start, self.end
def init_form(self):
today = datetime.date.today()
self.start = datetime.date(day=1, month=today.month, year=today.year)
self.end = today
return self.start, self.end
def get_form(self):
if not hasattr(self, 'form'):
req = self.request
start = req.GET.get('start', req.session.get('billing_start'))
end = req.GET.get('end', req.session.get('billing_end'))
if start and end:
# bound form
self.form = forms.DateForm({'start': start, 'end': end})
else:
# non-bound form
init = self.init_form()
start = init[0].isoformat()
end = init[1].isoformat()
self.form = forms.DateForm(initial={'start': start,
'end': end})
req.session['billing_start'] = start
req.session['billing_end'] = end
return self.form
def get_billing_list(self, start, end):
return []
def csv_link(self):
form = self.get_form()
data = {}
if hasattr(form, "cleaned_data"):
data = form.cleaned_data
if not ('start' in data and 'end' in data):
data = {"start": self.today.date(), "end": self.today.date()}
return "?start=%s&end=%s&format=csv" % (data['start'],
data['end'])

View File

@ -18,103 +18,116 @@
{% block main %}
<p>Disclaimer: This is an estimate for your usage within the current region, not your final invoice. It includes the free router and network discount. All costs are in New Zealand dollars and are exclusive of GST.</p>
<p>Disclaimer: This is an estimate for your usage cross <b>ALL regions</b>, not your final invoice. It includes the free router and network discount. All costs are in New Zealand dollars and are exclusive of GST.</p>
<div class="row-fluid">
<div class="col-md-4">
<h3 class="quota-heading dot_line">{% trans "Month to date" %}</h3>
<div id="pie_chart">
<svg class="pie"></svg>
<div id="credits_div" class="list-group">
<h4 class="quota-heading dot_line">{% trans "Credits" %}</h4>
<ul id="credits_list" class="fa-ul" style="margin-left: 1.7em">
</ul>
</div>
<div>
<h4 class="quota-heading dot_line">{% trans "Usage Cost History" %}
</h4>
<div id="line_chart">
<svg class="line"></svg>
</div>
{{table.render}}
</div>
<div class="col-md-8">
<h3 class="quota-heading dot_line">{% trans "Usage cost history" %}</h3>
<!-- Remove the date range for now
<form action="?" method="get" id="date_form" class="form-horizontal">
<h3>{% trans "Select a period of time to query its cost" %}: </h3>
<div class="datepicker">
{% blocktrans with start=form.start %}
<label>From:</label>{{ start }}{% endblocktrans %}
{% blocktrans with end=form.end %}
<label>To:</label>{{ end }}{% endblocktrans %}
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
<small>{% trans "The date should be in YYYY-mm-dd format." %}</small>
<div>
<h4 id="monthly_title" class="quota-heading dot_line">{% trans "Monthly Cost Break Down" %}</h4>
<div class="col-md-4" style="padding:0px;">
<div id="pie_chart">
<select id="month_select" class="form-control" style="width:95%;"></select>
<svg class="pie"></svg>
</div>
</div>
</form>
-->
<div class="col-md-8" style="padding:0px;">
<table id="month_details" class="table table-striped">
<thead>
<tr>
<th>Product Name</th>
<th>Resource Name/ID</th>
<th>Quantity</th>
<th>Unit</th>
<th>Rate</th>
<th>Cost</th>
</tr>
</thead>
<tfoot>
<tr>
<th>Product Name</th>
<th>Resource Name/ID</th>
<th>Quantity</th>
<th>Unit</th>
<th>Rate</th>
<th>Cost</th>
</tr>
</tfoot>
<tbody>
<div id="line_chart">
<svg class="line"></svg>
</div>
<table id="service_details" class="table table-striped datatable tablesorter tablesorter-default">
<thead>
<tr class="table_caption">
<th data-column="0" class="table_header" colspan="6">
<h3 class="table_title">Usage Details</h3>
</th>
</tr>
<tr class="tablesorter-headerRow">
<th tabindex="0" data-column="0" class="sortable normal_column tablesorter-header"><div class="tablesorter-header-inner">Resource ID</div></th>
<th tabindex="0" data-column="1" class="sortable normal_column tablesorter-header"><div class="tablesorter-header-inner">Resource Name</div></th>
<th tabindex="0" data-column="2" class="sortable normal_column tablesorter-header"><div class="tablesorter-header-inner">Volume</div></th>
<th tabindex="0" data-column="3" class="sortable normal_column tablesorter-header"><div class="tablesorter-header-inner">Unit</div></th>
<th tabindex="0" data-column="4" class="sortable normal_column tablesorter-header"><div class="tablesorter-header-inner">Rate</div></th>
<th tabindex="0" data-column="5" class="sortable normal_column tablesorter-header"><div class="tablesorter-header-inner">Cost</div></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</tbody>
</table>
</div>
</div>
</div>
<!-- Current d3 version is 3.4.1 -->
<script type="text/javascript" src="{{ STATIC_URL }}catalystdashboard/d3.min.js" charset="utf-8"></script>
<script type="text/javascript" src="{{ STATIC_URL }}catalystdashboard/nv.d3.min.js" charset="utf-8"></script>
<script type="text/javascript" src="{{ STATIC_URL }}catalystdashboard/jquery.simplePagination.js" charset="utf-8"></script>
<script type="text/javascript">
var CHART_DATA = {{chart_data | safe}};
var AMOUNT_COST = {{amount_cost | safe}};
var COST_DETAILS = {{cost_details | safe}};
var LINE_CHART_DATA = {{line_chart_data | safe}};
var PIE_CHART_DATA = {{pie_chart_data | safe}};
var MONTH_DETAILS = {{month_details | safe}};
var MONTHS = {{x_axis_line_chart | safe}};
var CREDITS = {{credits | safe}};
function draw_pie(where, source){
var h = 500;
var r = h/2;
var arc = d3.svg.arc().outerRadius(r) /2 ;
nv.addGraph(function() {
var chart = nv.models.pieChart()
.x(function(d) { return d.key })
.y(function(d) { return d.value })
.margin({left: 0, right: 50})
.showLabels(true)
.labelType("percent")
.labelThreshold(.05)
.donut(true)
.donutRatio(0.35);
chart.tooltipContent(function(k, v, graph) {
return '<h3>' + k + '</h3>' + '<span style=\'padding:8px\'>$' + v + '</span>'
});
d3.select(where)
.donut(true).donutRatio(0.35);
d3.select("#pie_chart svg")
.datum(source)
.transition().duration(1200)
.call(chart);
nv.utils.windowResize(chart.update);
return chart;
});
}
function draw_line(where, source){
nv.addGraph(function() {
var months = {{x_axis_line_chart | safe}};
d3.select(source).remove();
var chart = nv.models.lineChart()
.margin({left: 75})
.size(70);
.margin({left: 75, right: 50})
.size(100);
chart.tooltipContent(function(key, x, y, graph) {
return '<h3>' + key + '</h3>' + '<span style=\'padding:8px\'>$' + y + ' at ' + x + '</span>'
pay_status = "";
if (key == "Cost"){
status = LINE_CHART_DATA[0].values[MONTHS.indexOf(x)].p;
pay_status = "<br/>which is a quotation";
switch(status) {
case "paid":
pay_status = "<br/>which has been paid";
break;
case "open":
pay_status = '<br/>which has <strong style="color: #E6550D;">NOT</strong> been paid';
break;
default:
text = "<br/>which is a quotation";
}
}
return '<h3>' + key + '</h3>' + '<span style=\'padding:8px\'>$' + y + ' at ' + x + pay_status + '</span>'
});
chart.legend
@ -122,15 +135,47 @@
chart.xAxis.axisLabel("Cost per Month")
.tickValues([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
.tickFormat(function(m){return months[m]});
.tickFormat(function(m){return MONTHS[m]});
chart.yAxis
.axisLabel("Cost (NZD) excl. GST")
.tickFormat(d3.format(',.2f'));
d3.select(where).select('.nv-interactive').selectAll("circle").remove();
d3.select(where)
.datum(source)
.call(chart);
.transition()
.duration(500)
.call(chart)
.each("end", function(){
var svg = d3.select(this);
var data = LINE_CHART_DATA;
var unpaid = svg.select('.nv-interactive').selectAll("circle")
.data(data[0].values.filter(function (d) {
return d.p == 'open';
}))
.enter().append("circle")
.attr("class", "unpaid")
.style("fill", "#E6550D")
.attr("r", 6)
.attr("cx", function(d) {
return chart.xAxis.scale()(d.x);
})
.attr("cy", function(d) {
return chart.yAxis.scale()(d.y);
});
// .on("click", function(d) {
// showMonthlyCost(d["x"]);
//});
current_month = LINE_CHART_DATA[0].values[11];
var current = svg.select('.nv-interactive')
.append("circle")
.attr("class", "mycircle")
.style("fill", "#31A354")
.attr("r", 6)
.attr("cx", function(d) {return chart.xAxis.scale()(current_month.x);})
.attr("cy", function(d) {return chart.yAxis.scale()(current_month.y);});
});
nv.utils.windowResize(chart.update);
@ -138,51 +183,81 @@
});
}
$(document).ready(function(){
draw_pie("#pie_chart .pie", function(){
return CHART_DATA['pie'];
})
draw_line("#line_chart .line", function(){
return CHART_DATA['line'];
})
$('#id_start').attr('disabled', true);
$('#billing tbody tr').each(function(){
$(this).find("td").eq(2).html('$' + $(this).find("td").eq(2).html());
});
$('#billing tfoot td').attr('colspan', 2);
$('#billing tfoot>tr').append('<td><span class="total">$'+AMOUNT_COST+'</span></td>');
var link_mapping = {"Compute": "/instances/",
"Image": "/images/",
"Network": "/networks/",
"Router": "/routers/",
"Block Storage": "/volumes/",
"VPN": "/vpn/vpnservice/"}
$("#billing tbody>tr a")
.click(function() {
var resource_type = $(this).html();
res_cost_details = COST_DETAILS[resource_type]
$('#service_details .table_title').html(resource_type + ' - Usage Details')
$('#service_details').show();
$('#service_details tbody').html('');
if (res_cost_details.length>0){
for (i = 0; i < res_cost_details.length; i++) {
var row_class = i%2==0? 'odd':'even';
var link = "#";
if (resource_type in link_mapping){
link = "/project" + link_mapping[resource_type] + res_cost_details[i]['resource_id'];
}
if (resource_type == 'Network'){
link += '/detail';
}
$('#service_details tbody').append('<tr class='+row_class+'><td><a href="' + link +'">' + res_cost_details[i]['resource_id'] + '</a></td><td>'+res_cost_details[i]['resource']+'</td><td>'+res_cost_details[i]['volume']+'</td><td>'+res_cost_details[i]['unit']+'</td><td>'+res_cost_details[i]['rate']+'</td><td>'+res_cost_details[i]['cost']+'</td></tr>');
}
function showMonthlyCost(monthIndex){
// Build the details table
var link_mapping = {"c1": "/project/instances/",
"b1": "/project/volumes/"}
$('#month_details tbody').empty();
month_detail = MONTH_DETAILS[monthIndex]
for(i = 0; i < month_detail.length; i++) {
var resource_id = ""
var resource_url = "#";
if (month_detail[i]['resource_id'] != null && month_detail[i]['resource_id'] != "") {
resource_id = "(" + month_detail[i]['resource_id']+")"
var resource_type = month_detail[i]["product"].split(".")[1];
var product_name = month_detail[i]["product"].split(".")[2];
if (resource_type in link_mapping){
resource_url = link_mapping[resource_type] + month_detail[i]['resource_id'];
}
if (resource_type == 'n1'){
if (product_name == 'network'){
resource_url = '/project/networks/'+ month_detail[i]['resource_id'] +'/detail';
}
if (product_name == 'router'){
resource_url = '/project/routers/'+ month_detail[i]['resource_id'];
}
if (product_name == 'vpn'){
resource_url = '/project/vpn/vpnservice/'+ month_detail[i]['resource_id'];
}
}
}
})
$('#service_details').hide();
resource = resource_id == ""? month_detail[i]['resource_name']+resource_id : "<a href="+ resource_url +">" + month_detail[i]['resource_name'] + resource_id + "</a>"
$('#month_details tbody').append('<tr><td>' + month_detail[i]['product'] + '</td><td>' + resource +'</td><td>'+month_detail[i]['quantity']+'</td><td>'+month_detail[i]['unit']+'</td><td>'+month_detail[i]['rate']+'</td><td>$'+month_detail[i]['cost']+'</td></tr>');
}
$("#month_details").simplePagination({
perPage: 10,
});
// Refresh the pie chart
draw_pie("#pie_chart .pie", function(){
return PIE_CHART_DATA[monthIndex];
});
}
$(document).ready(function(){
draw_line("#line_chart .line", function(){
return LINE_CHART_DATA;
});
$(window).resize(function(){
draw_line("#line_chart .line", function(){
return LINE_CHART_DATA;
});
});
for(i = MONTHS.length -1 ; i >= 0; i--) {
amount = LINE_CHART_DATA[0].values[i]["y"]
$("#month_select").append('<option value="'+ i.toString() +'">'+MONTHS[i] + ': $' + amount +'</option>');
}
$("#month_select").change(function (e) {
var optionSelected = $("option:selected", this);
var valueSelected = this.value;
showMonthlyCost(this.value);
});
showMonthlyCost(11);
if (CREDITS["credits"].length == 0) {
$("#credits_div").hide();
} else {
$("#credits_div").show();
for(i=0;i<CREDITS["credits"].length;i++){
$("#credits_list").append('<li><i class="fa-li fa fa-credit-card"></i> Balance of ' + CREDITS["credits"][i].type + ' is $' + CREDITS["credits"][i].balance + ' will expired at ' + CREDITS["credits"][i].expiry_date + '</li>');
}
}
});
</script>

View File

@ -13,23 +13,26 @@
# limitations under the License.
import collections
import datetime
import mock
from mox3 import mox
import json
from freezegun import freeze_time
import mock
from distil_ui.content.billing import base
from distil_ui.content.billing import views
from django.utils import timezone
from horizon import forms
from openstack_dashboard.test import helpers as test
BILLITEM = collections.namedtuple('BillItem',
['id', 'resource', 'count', 'cost'])
FAKE_COST = [{'total_cost': 617.0, 'details': [{'quantity': 744.0, 'resource_name': '150.242.40.138', 'cost': 4.46, 'product': 'NZ-POR-1.n1.ipv4', 'rate': 0.006, 'unit': 'Hour(s)'}, {'quantity': 744.0, 'resource_name': '150.242.40.139', 'cost': 4.46, 'product': 'NZ-POR-1.n1.ipv4', 'rate': 0.006, 'unit': 'Hour(s)'}], 'breakdown': {'Network': 9.64}, 'paid': True, 'date': '2016-08-31', 'status': 'open'}, {'total_cost': 0, 'details': [], 'breakdown': {}, 'paid': True, 'date': '2016-09-30', 'status': None}, {'total_cost': 0, 'details': [], 'breakdown': {}, 'paid': True, 'date': '2016-10-31', 'status': None}, {'total_cost': 0, 'details': [], 'breakdown': {}, 'paid': True, 'date': '2016-11-30', 'status': None}, {'total_cost': 0, 'details': [], 'breakdown': {}, 'paid': True, 'date': '2016-12-31', 'status': None}, {'total_cost': 0, 'details': [], 'breakdown': {}, 'paid': True, 'date': '2017-01-31', 'status': None}, {'total_cost': 0, 'details': [], 'breakdown': {}, 'paid': True, 'date': '2017-02-28', 'status': None}, {'total_cost': 0, 'details': [], 'breakdown': {}, 'paid': True, 'date': '2017-03-31', 'status': None}, {'total_cost': 0, 'details': [], 'breakdown': {}, 'paid': True, 'date': '2017-04-30', 'status': None}, {'total_cost': 653.0, 'details': [{'quantity': 7440.0, 'resource_name': 'docker - root disk', 'cost': 3.72, 'product': 'NZ-POR-1.b1.standard', 'rate': 0.0005, 'unit': 'Gigabyte-hour(s)'}, {'quantity': 23808.0, 'resource_name': 'docker_tmp', 'cost': 11.9, 'product': 'NZ-POR-1.b1.standard', 'rate': 0.0005, 'unit': 'Gigabyte-hour(s)'}, {'quantity': 7440.0, 'resource_name': 'postgresql - root disk', 'cost': 3.72, 'product': 'NZ-POR-1.b1.standard', 'rate': 0.0005, 'unit': 'Gigabyte-hour(s)'}, {'quantity': 14880.0, 'resource_name': 'dbserver_dbvol', 'cost': 7.44, 'product': 'NZ-POR-1.b1.standard', 'rate': 0.0005, 'unit': 'Gigabyte-hour(s)'}, {'quantity': 37200.0, 'resource_name': 'server_dockervol', 'cost': 18.6, 'product': 'NZ-POR-1.b1.standard', 'rate': 0.0005, 'unit': 'Gigabyte-hour(s)'}, {'quantity': 37200.0, 'resource_name': 'docker_uservol', 'cost': 18.6, 'product': 'NZ-POR-1.b1.standard', 'rate': 0.0005, 'unit': 'Gigabyte-hour(s)'}, {'quantity': 23808.0, 'resource_name': 'docker_swap', 'cost': 11.9, 'product': 'NZ-POR-1.b1.standard', 'rate': 0.0005, 'unit': 'Gigabyte-hour(s)'}], 'breakdown': {'Block Storage': 75.88}, 'paid': True, 'date': '2017-05-31', 'status': 'paid'}, {'total_cost': 689.0, 'details': [{'quantity': 744.0, 'resource_name': 'postgresql', 'cost': 184.51, 'product': 'NZ-POR-1.c1.c4r8', 'rate': 0.248, 'unit': 'Hour(s)'}, {'quantity': 744.0, 'resource_name': 'docker', 'cost': 582.55, 'product': 'NZ-POR-1.c1.c8r32', 'rate': 0.783, 'unit': 'Hour(s)'}], 'breakdown': {'Compute': 767.06}, 'paid': True, 'date': '2017-06-30', 'status': 'paid'}, {'details': [{'quantity': 30000.0, 'resource_name': 'new_instance', 'cost': 15.0, 'product': 'REGIONTWO.b1.standard', 'rate': 0.0005, 'unit': 'second', 'resource_id': '22'}, {'quantity': 200, 'resource_name': 'my_block', 'cost': 2, 'product': 'REGIONONE.b1.standard', 'rate': 0.01, 'unit': 'hour', 'resource_id': '8'}, {'quantity': 30000.0, 'resource_name': 'my_instance', 'cost': 15.0, 'product': 'REGIONONE.b1.standard', 'rate': 0.0005, 'unit': 'second', 'resource_id': '2'}, {'quantity': 30000.0, 'resource_name': 'other_instance', 'cost': 15.0, 'product': 'REGIONONE.b1.standard', 'rate': 0.0005, 'unit': 'second', 'resource_id': '3'}, {'quantity': 50000.0, 'resource_name': 'my_container', 'cost': 13.5, 'product': 'NZ.o1.standard', 'rate': 0.00027, 'unit': 'gigabyte', 'resource_id': '1'}], 'status': None, 'date': '2017-07-10', 'breakdown': {'Virtual Machine': 30.0, 'Network': 2, 'Object Storage': 13.5}, 'total_cost': 60.5}] # noqa
FAKE_CREDITS = {'credits': [{'code': 'a9iberAn', 'type': 'Cloud Trial Credit', 'expiry_date': '2017-09-30', 'balance': 300.0, 'recurring': False, 'start_date': '2017-08-02 22:16:28'}]} # noqa
class FakeUser(object):
roles = [{'name': 'admin'}]
authorized_tenants = ["tenant_name"]
tenant_id = "fake_project_id"
def is_authenticated(self):
return True
@ -50,127 +53,77 @@ class FakeRequest(object):
GET.get = _get
class BaseBillingTests(test.TestCase):
"""FIXME(flwang): Move this test to rest_api_tests.py
Now we're putting the api test at here, since we don't want to hack
horizon too much. That means we don't want to put the api.py under /api
folder, at least for now.
"""
def setUp(self):
super(BaseBillingTests, self).setUp()
self.mocker = mox.Mox()
self.billing = base.BaseBilling(FakeRequest(), 'my_project_id')
self.year = 2017
self.month = 1
self.day = 30
def test_today(self):
delta = datetime.timedelta(seconds=1)
self.assertTrue(self.billing.today - timezone.now() < delta)
def test_get_start(self):
start = datetime.datetime(self.year, self.month, self.day, 0, 0, 0)
self.assertEqual(self.billing.get_start(self.year, self.month,
self.day),
timezone.make_aware(start, timezone.utc))
def test_get_end(self):
end = datetime.datetime(self.year, self.month, self.day, 23, 59, 59)
self.assertEqual(self.billing.get_end(self.year, self.month, self.day),
timezone.make_aware(end, timezone.utc))
def test_get_date_range(self):
args_start = (self.billing.today.year, self.billing.today.month, 1)
args_end = (self.billing.today.year, self.billing.today.month,
self.billing.today.day)
start = self.billing.get_start(*args_start)
end = self.billing.get_end(*args_end)
self.assertEqual(self.billing.get_date_range(),
(start, end))
@mock.patch('distil_ui.content.billing.base.BaseBilling.get_form')
def test_get_date_range_valid_form(self, mock_get_form):
start = datetime.datetime(self.year, self.month, self.day, 0, 0, 0)
end = datetime.datetime(self.year, self.month, self.day, 23, 59, 59)
myform = forms.DateForm({'start': start, 'end': end})
myform.data = {'start': start, 'end': end}
myform.cleaned_data = {'start': start, 'end': end}
mock_get_form.return_value = myform
self.assertEqual(self.billing.get_date_range(),
(timezone.make_aware(start, timezone.utc),
timezone.make_aware(end, timezone.utc)))
def test_init_form(self):
start = datetime.date(self.billing.today.year,
self.billing.today.month, 1)
end = datetime.date.today()
self.assertEqual(self.billing.init_form(), (start, end))
def test_get_form(self):
start = datetime.date(self.billing.today.year,
self.billing.today.month, 1).strftime("%Y-%m-%d")
end = datetime.date.today().strftime("%Y-%m-%d")
self.assertEqual(self.billing.get_form().initial,
{"start": start, "end": end})
def test_get_billing_list(self):
self.assertEqual(self.billing.get_billing_list(None, None), [])
def test_csv_link(self):
start = self.billing.today.strftime("%Y-%m-%d")
end = self.billing.today.strftime("%Y-%m-%d")
link = "?start={0}&end={1}&format=csv".format(start, end)
self.assertEqual(self.billing.csv_link(), link)
class IndexCsvRendererTest(test.TestCase):
def setUp(self):
super(IndexCsvRendererTest, self).setUp()
request = FakeRequest()
template = ""
context = {"current_month": [BILLITEM(id=1, resource='N/A',
count=1, cost=2)]}
content_type = "text/csv"
self.csvRenderer = views.IndexCsvRenderer(request=request,
template=template,
context=context,
content_type=content_type)
def test_get_row_data(self):
data = list(self.csvRenderer.get_row_data())
self.assertEqual(data, [('N/A', 1, u'2.00')])
class ViewsTests(test.TestCase):
def setUp(self):
super(ViewsTests, self).setUp()
project_id = "fake_project_id"
self.project_id = "fake_project_id"
kwargs = {"project_id": self.project_id}
self.view = views.IndexView()
self.view.kwargs = kwargs
self.view.request = FakeRequest()
self.view.billing = base.BaseBilling(self.request, project_id)
def test_get_template_names(self):
self.assertEqual(self.view.get_template_names(),
"management/billing/billing.csv")
@mock.patch('distil_ui.api.distil_v2.get_cost')
@mock.patch('distil_ui.api.distil_v2.get_credits')
@mock.patch('horizon.views.HorizonTemplateView.get_context_data')
@freeze_time("2017-08-10")
def test_get_context_data(self, mock_get_context_data,
mock_get_credits, mock_get_cost):
mock_get_cost.return_value = FAKE_COST
mock_get_credits.return_value = FAKE_CREDITS
mock_get_context_data.return_value = {}
kwargs = {"project_id": self.project_id}
context = self.view.get_context_data(**kwargs)
def test_get_content_type(self):
self.assertEqual(self.view.get_content_type(), "text/csv")
expect_line_chart_data = [{"values": [{"p": "open",
"x": 0, "y": 617.0},
{"p": None, "x": 1, "y": 0},
{"p": None, "x": 2, "y": 0},
{"p": None, "x": 3, "y": 0},
{"p": None, "x": 4, "y": 0},
{"p": None, "x": 5, "y": 0},
{"p": None, "x": 6, "y": 0},
{"p": None, "x": 7, "y": 0},
{"p": None, "x": 8, "y": 0},
{"p": "paid",
"x": 9, "y": 653.0},
{"p": "paid",
"x": 10, "y": 689.0},
{"p": None,
"x": 11, "y": 60.5}],
"key": "Cost"},
{"values":
[{"x": 0, "y": 178.09},
{"x": 1, "y": 178.09},
{"x": 2, "y": 178.09},
{"x": 3, "y": 178.09},
{"x": 4, "y": 178.09},
{"x": 5, "y": 178.09},
{"x": 6, "y": 178.09},
{"x": 7, "y": 178.09},
{"x": 8, "y": 178.09},
{"x": 9, "y": 178.09},
{"x": 10, "y": 178.09},
{"x": 11, "y": 178.09}],
"key": "Avg Cost", "color": "#fdd0a2"}]
self.assertDictEqual(json.loads(context["line_chart_data"])[0],
expect_line_chart_data[0])
def test_get_data(self):
# TODO(flwang): Will add in next patch
pass
expect_credits = {"credits": [{"balance": 300.0, "code": "a9iberAn",
"start_date": "2017-08-02 22:16:28",
"expiry_date": "2017-09-30",
"recurring": False,
"type": "Cloud Trial Credit"}]}
self.assertDictEqual(json.loads(context["credits"]), expect_credits)
@mock.patch('horizon.tables.DataTableView.get_context_data')
def test_get_context_data(self, mock_get_context_data):
# TODO(flwang): Will add in next patch
pass
expect_axis = ['Sep 2016', 'Oct 2016', 'Nov 2016', 'Dec 2016',
'Jan 2017', 'Feb 2017', 'Mar 2017', 'Apr 2017',
'May 2017', 'Jun 2017', 'Jul 2017', 'Aug 2017']
self.assertEqual(context["x_axis_line_chart"], expect_axis)
def test_render_to_response(self):
self.view.start = datetime.datetime.now()
self.view.end = datetime.datetime.now()
context = {"current_month": [BILLITEM(id=1, resource='N/A',
count=1, cost=2)]}
self.assertIsInstance(self.view.render_to_response(context),
views.IndexCsvRenderer)
@freeze_time("2017-08-10")
def test_get_x_axis_for_line_chart(self):
x_axis = self.view._get_x_axis_for_line_chart()
expect = ['Sep 2016', 'Oct 2016', 'Nov 2016', 'Dec 2016',
'Jan 2017', 'Feb 2017', 'Mar 2017', 'Apr 2017',
'May 2017', 'Jun 2017', 'Jul 2017', 'Aug 2017']
self.assertEqual(x_axis, expect)

View File

@ -14,103 +14,53 @@
import datetime
import json
import logging
from django.template import defaultfilters
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import tables as horizon_tables
from horizon.utils import csvbase
from horizon import views
from distil_ui.api import distil
from distil_ui.content.billing import base
from distil_ui.content.billing import tables
from distil_ui.api import distil_v2 as distil
LOG = logging.getLogger(__name__)
class IndexCsvRenderer(csvbase.BaseCsvResponse):
columns = [_("Resource"), _("Count"), _("Cost")]
def get_row_data(self):
for b in self.context['current_month']:
yield (b.resource,
b.count,
defaultfilters.floatformat(b.cost, 2))
class IndexView(horizon_tables.DataTableView):
table_class = tables.BillingTable
show_terminated = True
csv_template_name = 'management/billing/billing.csv'
class IndexView(views.HorizonTemplateView):
template_name = 'management/billing/index.html'
csv_response_class = IndexCsvRenderer
def __init__(self, *args, **kwargs):
super(IndexView, self).__init__(*args, **kwargs)
def get_template_names(self):
if self.request.GET.get('format', 'html') == 'csv':
return (self.csv_template_name or
".".join((self.template_name.rsplit('.', 1)[0], 'csv')))
return self.template_name
def get_content_type(self):
if self.request.GET.get('format', 'html') == 'csv':
return "text/csv"
return "text/html"
def get_data(self):
try:
project_id = self.kwargs.get('project_id',
self.request.user.tenant_id)
self.billing = base.BaseBilling(self.request, project_id)
self.start, self.end = self.billing.get_date_range()
distil_client = distil.distilclient(self.request)
self.history = (distil.get_cost(self.request, distil_client))
self.kwargs['billing'] = self.billing
self.kwargs['current_month'] = self.history[-1][1]
self.kwargs['history'] = self.history
return self.history[-1][1]
except Exception:
exceptions.handle(self.request, _('Unable to get usage cost.'))
return []
def get_context_data(self, **kwargs):
context = super(IndexView, self).get_context_data(**kwargs)
context['table'].kwargs['billing'] = self.billing
context['form'] = self.billing.form
context['billing'] = self.billing
context['current_month'] = self.history[-1][1]
pie_data = [{"value": b.cost, "key": b.resource}
for b in self.history[-1][1] if b.cost >= 0]
line_data = [{"values": [{"y": m[0], "x": i}
for i, m in enumerate(self.history)],
"method": "Square Root Choice", "key": "Cost"}]
chart_data = {'pie': pie_data, 'line': line_data}
context['chart_data'] = json.dumps(chart_data)
context['amount_cost'] = self.history[-1][0]
context['cost_details'] = json.dumps(self.history[-1][2])
distil_client = distil.distilclient(self.request)
self.cost = distil.get_cost(self.request, distil_client)
self.credits = distil.get_credits(self.request, distil_client)
pie_data = []
for i in range(len(self.cost)):
pie_data.append([{"value": value, "key": key} for (key, value)
in self.cost[i]["breakdown"].items()])
# NOTE(flwang): The average cost is removed for now until we can get
# a better performance of the API.
# avg_cost = round(sum([m["total_cost"]
# for m in self.cost[:11]]) / 11.0, 2)
line_data = [{"values": [{"y": round(m["total_cost"], 2), "x": i,
"p": m.get("status")} for i, m
in enumerate(self.cost)], "key": "Cost"}]
# {"values": [{"y": avg_cost, "x": i}
# for i in range(12)],
# "key": "Avg Cost", "color": "#fdd0a2"}]
context['line_chart_data'] = json.dumps(line_data)
context['pie_chart_data'] = json.dumps(pie_data)
context['month_details'] = json.dumps([d["details"] for d
in self.cost])
context['x_axis_line_chart'] = self._get_x_axis_for_line_chart()
context['credits'] = json.dumps(self.credits)
return context
def _get_x_axis_for_line_chart(self):
today = datetime.date.today()
ordered_month = ['Jan ' + str(today.year), 'Feb', 'Mar', "Apr", 'May',
'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
ordered_month = ['Jan ', 'Feb ', 'Mar ', "Apr ", 'May ', 'Jun ',
'Jul ', 'Aug ', 'Sep ', 'Oct ', 'Nov ', 'Dec ']
return ordered_month[today.month:] + ordered_month[:today.month]
def render_to_response(self, context, **response_kwargs):
if self.request.GET.get('format', 'html') == 'csv':
render_class = self.csv_response_class
fn_template = "usage_cost_{0}_{1}.csv"
filename = fn_template.format(self.start.strftime('%Y-%m-%d'),
self.end.strftime('%Y-%m-%d'))
response_kwargs.setdefault("filename", filename)
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
return ([m + str(today.year - 1) for m in ordered_month[today.month:]]
+ [m + str(today.year) for m in ordered_month[:today.month]])

View File

@ -0,0 +1,8 @@
from django.utils.translation import ugettext_lazy as _
# The slug of the panel group to be added to HORIZON_CONFIG. Required.
PANEL_GROUP = 'billing_group'
# The display name of the PANEL_GROUP. Required.
PANEL_GROUP_NAME = _('Billing')
# The slug of the dashboard the PANEL_GROUP associated with. Required.
PANEL_GROUP_DASHBOARD = 'management'

View File

@ -13,7 +13,7 @@
# limitations under the License.
PANEL = 'billing'
PANEL_GROUP = 'default'
PANEL_GROUP = 'billing_group'
PANEL_DASHBOARD = 'management'
ADD_PANEL = ('distil_ui.content.billing.panel.Billing')

View File

@ -0,0 +1,139 @@
/**
The MIT License (MIT)
Copyright (c) 2015 Sebastian Marulanda http://marulanda.me
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
(function($) {
$.fn.simplePagination = function(options) {
var defaults = {
perPage: 5,
containerClass: '',
containerID: 'pager',
previousButtonClass: 'btn btn-default',
nextButtonClass: 'btn btn-default',
firstButtonClass: 'btn btn-default',
lastButtonClass: 'btn btn-default',
firstButtonText: 'First',
lastButtonText: 'Last',
previousButtonText: 'Prev',
nextButtonText: 'Next',
currentPage: 1
};
var settings = $.extend({}, defaults, options);
return this.each(function() {
$("#" + settings.containerID).remove();
var $rows = $('tbody tr', this);
var pages = Math.ceil($rows.length/settings.perPage);
var container = document.createElement('div');
container.id = settings.containerID;
var bFirst = document.createElement('button');
var bPrevious = document.createElement('button');
var bNext = document.createElement('button');
var bLast = document.createElement('button');
var of = document.createElement('span');
bPrevious.innerHTML = settings.previousButtonText;
bNext.innerHTML = settings.nextButtonText;
bFirst.innerHTML = settings.firstButtonText;
bLast.innerHTML = settings.lastButtonText;
container.className = settings.containerClass;
bPrevious.className = settings.previousButtonClass;
bNext.className = settings.nextButtonClass;
bFirst.className = settings.firstButtonClass;
bLast.className = settings.lastButtonClass;
bPrevious.style.marginRight = '8px';
bNext.style.marginLeft = '8px';
bFirst.style.marginRight = '8px';
bLast.style.marginLeft = '8px';
container.style.textAlign = "center";
container.style.marginBottom = '20px';
container.appendChild(bFirst);
container.appendChild(bPrevious);
container.appendChild(of);
container.appendChild(bNext);
container.appendChild(bLast);
$(this).after(container);
update();
$(bFirst).click(function() {
settings.currentPage = 1;
update();
});
$(bLast).click(function() {
settings.currentPage = pages;
update();
});
$(bNext).click(function() {
if (settings.currentPage + 1 > pages) {
settings.currentPage = pages;
} else {
settings.currentPage++;
}
update();
});
$(bPrevious).click(function() {
if (settings.currentPage - 1 < 1) {
settings.currentPage = 1;
} else {
settings.currentPage--;
}
update();
});
function update() {
var from = ((settings.currentPage - 1) * settings.perPage) + 1;
var to = from + settings.perPage - 1;
if (to > $rows.length) {
to = $rows.length;
}
$rows.hide();
$rows.slice((from-1), to).show();
of.innerHTML = from + ' to ' + to + ' of ' + $rows.length + ' entries';
if ($rows.length <= settings.perPage) {
$(container).hide();
} else {
$(container).show();
}
}
});
}
}(jQuery));

View File

@ -14,13 +14,12 @@
}
.pie {
width:100%;
height:360px
height:500px;
}
.line {
width:100%;
height:360px;
height:200px;
}
#billing .total {

View File

@ -1,297 +0,0 @@
# Copyright (c) 2014 Catalyst IT Ltd.
#
# 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
import math
import mock
import time
from mox3 import mox
from distil_ui.api import distil
from openstack_dashboard.test import helpers as test
class FakeUser(object):
roles = [{'name': 'admin'}]
token = mock.MagicMock()
tenant_id = "fake"
services_region = "fake"
class FakeRequest(object):
user = FakeUser()
class FakeDistilClient(object):
"""A fake distil client for unit test."""
endpoint = 'http://localhost:8788'
request = FakeRequest()
def get_rated(self, tenant, start, end):
raise NotImplemented()
class BillingTests(test.TestCase):
"""FIXME(flwang): Move this test to rest_api_tests.py
Now we're putting the api test at here, since we don't want to hack
horizon too much. That means we don't want to put the api.py under /api
folder, at least for now.
"""
def setUp(self):
super(BillingTests, self).setUp()
self.mocker = mox.Mox()
@mock.patch("openstack_dashboard.api.base.url_for")
def test_init_distilclient(self, mock_url_for):
request = FakeRequest()
distilclient = distil.distilclient(request)
self.assertIsNotNone(distilclient)
def test_calculate_end_date(self):
start = datetime.date(2015, 1, 1)
end = distil._calculate_end_date(start)
self.assertEqual((end.year, end.month, end.day), (2015, 2, 1))
start = datetime.date(2015, 6, 1)
end = distil._calculate_end_date(start)
self.assertEqual((end.year, end.month, end.day), (2015, 7, 1))
start = datetime.date(2015, 12, 1)
end = distil._calculate_end_date(start)
self.assertEqual((end.year, end.month, end.day), (2016, 1, 1))
def test_get_month_cost(self):
distilclient = self.mocker.CreateMock(FakeDistilClient)
resources = {"fake_uuid_1": {"services": [{
"volume": 2100,
"rate": 0.0005,
"cost": 1.05,
"name": "b1.standard",
"unit": "gigabyte"}],
"total_cost": 1.05,
"type": "Image",
"name": "cirros"},
"fake_uuid_2": {"services": [{
"volume": 122,
"rate": 0.048,
"cost": 5.86,
"name": "m1.tiny",
"unit": "hour"}],
"total_cost": 5.86,
"type": "Virtual Machine",
"name": "dfgh"},
"fake_uuid_3": {"services": [{
"volume": 200,
"rate": 0.048,
"cost": 9.60,
"name": "m1.tiny",
"unit": "hour"}],
"total_cost": 9.60,
"type": "Virtual Machine",
"name": "abcd"},
"fake_uuid_4": {"services": [{"volume": 20.00,
"rate": 0.016,
"cost": 0.32,
"name": "n1.network",
"unit": "hour"},
{"volume": 10.00,
"rate": 0.016,
"cost": 0.16,
"name": "n1.network",
"unit": "hour"}],
"total_cost": 0.48,
"type": "Network",
"name": "public"}
}
result = {'usage': {"end": "2011-03-01 00:00:00", "name": "openstack",
"total_cost": 7.23,
"tenant_id": "7c3c506ad4b943f5bb12b9fb69478084",
"start": "2011-02-01 00:00:00",
"resources": resources
}
}
distilclient.get_rated([self.tenant.id],
'2011-02-01T00:00:00',
'2011-03-01T00:00:00').AndReturn(result)
self.mocker.ReplayAll()
cost = [()]
distil._get_month_cost(distilclient,
self.tenant.id,
'2011-02-01T00:00:00',
'2011-03-01T00:00:00',
cost, 0)
self.assertEqual(16.99, cost[0][0])
self.assertEqual(3, len(cost[0][1]))
bill_items = {}
for b in cost[0][1]:
# Convert cost to string make sure the floating number is right
bill_items[b.resource] = (b.count, str(b.cost))
self.assertEqual((2, '0.48'), bill_items['Network'])
self.assertEqual((2, '15.46'), bill_items['Compute'])
self.assertEqual((1, '1.05'), bill_items['Image'])
def test_calculate_history_date(self):
"""Using the same algorithm to calculate the history date."""
today = datetime.date(2015, 2, 17)
start = distil._calculate_start_date(datetime.date(2015, 2, 17))
end = distil._calculate_end_date(start)
final_end = datetime.datetime(today.year, today.month + 1, 1)
history_date = [None for i in range(12)]
for i in range(12):
start_str = start.strftime("%Y-%m-%dT00:00:00")
end_str = end.strftime("%Y-%m-%dT00:00:00")
history_date[i] = (start_str, end_str)
start = end
end = distil._calculate_end_date(start)
if end > final_end:
break
self.assertEqual(('2014-03-01T00:00:00', '2014-04-01T00:00:00'),
history_date[0])
self.assertEqual(('2014-04-01T00:00:00', '2014-05-01T00:00:00'),
history_date[1])
self.assertEqual(('2014-05-01T00:00:00', '2014-06-01T00:00:00'),
history_date[2])
self.assertEqual(('2014-06-01T00:00:00', '2014-07-01T00:00:00'),
history_date[3])
self.assertEqual(('2014-07-01T00:00:00', '2014-08-01T00:00:00'),
history_date[4])
self.assertEqual(('2014-08-01T00:00:00', '2014-09-01T00:00:00'),
history_date[5])
self.assertEqual(('2014-09-01T00:00:00', '2014-10-01T00:00:00'),
history_date[6])
self.assertEqual(('2014-10-01T00:00:00', '2014-11-01T00:00:00'),
history_date[7])
self.assertEqual(('2014-11-01T00:00:00', '2014-12-01T00:00:00'),
history_date[8])
self.assertEqual(('2014-12-01T00:00:00', '2015-01-01T00:00:00'),
history_date[9])
self.assertEqual(('2015-01-01T00:00:00', '2015-02-01T00:00:00'),
history_date[10])
self.assertEqual(('2015-02-01T00:00:00', '2015-03-01T00:00:00'),
history_date[11])
def test_get_cost(self):
distilclient = self.mocker.CreateMock(FakeDistilClient)
today = datetime.date.today()
start = distil._calculate_start_date(datetime.date.today())
end = distil._calculate_end_date(start)
final_end = datetime.datetime(today.year, today.month + 1, 1)
for i in range(12):
result = {'usage': {'total_cost': (i + 1) * 100,
'resources': {'uuid': {"services": [{
"volume": 2100,
"rate": 0.0005,
"cost": 1.05,
"name": "b1.standard",
"unit": "gigabyte"}],
"total_cost": 1.05,
"type": "Image",
"name": "cirros"}}}}
start_str = start.strftime("%Y-%m-%dT00:00:00")
end_str = end.strftime("%Y-%m-%dT00:00:00")
distilclient.get_rated([self.tenant.id],
start_str,
end_str).AndReturn(result)
start = end
end = distil._calculate_end_date(start)
if end > final_end:
break
self.mocker.ReplayAll()
setattr(self.request.user, 'tenant_id', self.tenant.id)
history_cost = distil.get_cost(self.request,
distil_client=distilclient,
enable_eventlet=False)
# 2 = math.ceil(1.05)
self.assertEqual([1.05 for i in range(12)],
[c[0] for c in history_cost])
def test_apply_discount(self):
# There are 3 scenarios for current month.
cost = (47.54,
[distil.BILLITEM(id=1, resource='Compute', count=9,
cost=31.76),
distil.BILLITEM(id=2, resource=u'Network', count=3, cost=1.5),
distil.BILLITEM(id=3, resource=u'Image', count=35, cost=3.82),
distil.BILLITEM(id=4, resource=u'Router', count=2, cost=0.96),
distil.BILLITEM(id=5, resource=u'Floating IP', count=21,
cost=3.57),
distil.BILLITEM(id=6, resource='Block Storage', count=22,
cost=6.08)
], [])
prices = {u'Virtual Machine': 0.044, u'Network': 0.016,
u'Image': 0.0005, u'Volume': 0.0005,
u'Router': 0.017, u'Floating IP': 0.006}
start_str = '2015-07-01T00:00:00'
end_str = '2015-07-02T04:00:00'
cost_after_discount = distil._apply_discount(cost, start_str, end_str,
prices)
start = time.mktime(time.strptime(start_str, '%Y-%m-%dT%H:%M:%S'))
end = time.mktime(time.strptime(end_str, '%Y-%m-%dT%H:%M:%S'))
free_hours = math.floor((end - start) / 3600)
free_network_cost = round(0.016 * free_hours, 2)
free_router_cost = round(0.017 * free_hours, 2)
self.assertEqual(cost[0] - free_network_cost - free_router_cost,
cost_after_discount[0])
self.assertIn(distil.BILLITEM(id=2, resource=u'Network', count=3,
cost=1.05),
cost_after_discount[1])
self.assertIn(distil.BILLITEM(id=4, resource=u'Router', count=2,
cost=0.48),
cost_after_discount[1])
def test_get_month_cost_with_cache(self):
distil.CACHE.clear()
distilclient = self.mocker.CreateMock(FakeDistilClient)
result = {'usage': {'total_cost': 5.05,
'resources': {'uuid':
{"services": [{"volume": 2100,
"rate": 0.0005,
"cost": 5.05,
"name": "b1.standard",
"unit": "gigabyte"}],
"total_cost": 5.05,
"type": "Image",
"name": "cirros"}}}}
distilclient.get_rated([self.tenant.id],
'2011-02-01T00:00:00',
'2011-03-01T00:00:00').AndReturn(result)
self.mocker.ReplayAll()
cost = [()]
distil._get_month_cost(distilclient,
self.tenant.id,
'2011-02-01T00:00:00',
'2011-03-01T00:00:00',
cost, 0)
key = 'http://localhost:8788_1_2011-02-01T00:00:00_2011-03-01T00:00:00'
self.assertIn(key, distil.CACHE)
self.assertEqual(distil.CACHE[key][0], 5.05)

View File

@ -0,0 +1,232 @@
# Copyright (c) 2017 Catalyst IT Ltd.
#
# 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
import mock
from distil_ui.api import distil_v2
from freezegun import freeze_time
from openstack_dashboard.test import helpers as test
regionOne = mock.Mock(id='RegionOne')
regionTwo = mock.Mock(id='RegionTwo')
region_list = [regionOne, regionTwo]
fake_keystoneclient = mock.MagicMock()
fake_keystoneclient.regions.list = mock.Mock(return_value=region_list)
get_fake_keystoneclient = mock.Mock(return_value=fake_keystoneclient)
class FakeUser(object):
roles = [{'name': 'admin'}]
token = mock.MagicMock()
tenant_id = "fake"
services_region = "fake"
available_services_regions = ["RegionOne", "RegionTwo"]
class FakeRequest(object):
user = FakeUser()
class FakeDistilClient(mock.MagicMock):
def __init__(self, *args, **kwargs):
super(FakeDistilClient, self).__init__(*args, **kwargs)
self.region_id = kwargs.get('region_id')
self.quotations = mock.MagicMock()
if self.region_id == 'RegionOne':
self.quotations.list = mock.Mock(return_value={
"quotations": {"2017-07-10": {"details": {
"Object Storage": {
"breakdown": {
"REGIONONE.o1.standard": [
{
"cost": 13.5,
"quantity": 50000.0,
"rate": 0.00027,
"resource_id": "1",
"resource_name": "my_container",
"unit": "gigabyte"
}
]
},
"total_cost": 13.5
},
"Virtual Machine": {
"breakdown": {
"REGIONONE.c1.c2r2": [
{
"cost": 15.0,
"quantity": 30000.0,
"rate": 0.0005,
"resource_id": "22",
"resource_name": "new_instance",
"unit": "second"
}
]
},
"total_cost": 15.0
}
}, "total_cost": 28.5}}})
elif self.region_id == 'RegionTwo':
self.quotations.list = mock.Mock(return_value={
"quotations": {"2017-07-10": {"details": {
"Network": {"breakdown": {"REGIONTWO.b1.standard": [
{"cost": 2,
"quantity": 200,
"rate": 0.01,
"resource_id": "8",
"resource_name": "my_block",
"unit": "hour"}]},
"total_cost": 2
},
"Object Storage": {"breakdown": {"REGIONTWO.o1.standard": [
{"cost": 13.5,
"quantity": 50000.0,
"rate": 0.00027,
"resource_id": "1",
"resource_name": "my_container",
"unit": "gigabyte"}]},
"total_cost": 13.5},
"Virtual Machine": {"breakdown": {
"REGIONTWO.c1.c1r1": [
{"cost": 15.0,
"quantity": 30000.0,
"rate": 0.0005,
"resource_id": "2",
"resource_name": "my_instance",
"unit": "second"},
{"cost": 15.0,
"quantity": 30000.0,
"rate": 0.0005,
"resource_id": "3",
"resource_name": "other_instance",
"unit": "second"}]
},
"total_cost": 30.0
}
}, "total_cost": 45.5
}}
})
self.invoices = mock.MagicMock()
self.invoices.list = mock.Mock(return_value={
"start": "2016-08-31 00:00:00",
"end": "2017-07-01 00:00:00",
"invoices": {
"2017-06-30": {
"total_cost": 689.0,
"status": "paid",
"details": {'Compute': {'total_cost': 767.06, 'breakdown':
{'NZ-POR-1.c1.c4r8': [{'rate': 0.248, 'resource_name': 'postgresql', 'cost': 184.51, 'unit': 'Hour(s)', 'quantity': 744.0}], # noqa
'NZ-POR-1.c1.c8r32': [{'rate': 0.783, 'resource_name': 'docker', 'cost': 582.55, 'unit': 'Hour(s)', 'quantity': 744.0}]} # noqa
}
}
},
"2017-05-31": {
"total_cost": 653.0,
"status": "paid",
"details": {'Block Storage': {'total_cost': 75.88,
'breakdown': {'NZ-POR-1.b1.standard': [{'rate': 0.0005, 'resource_name': 'docker - root disk', 'cost': 3.72, 'unit': 'Gigabyte-hour(s)', 'quantity': 7440.0}, {'rate': 0.0005, 'resource_name': 'docker_tmp', 'cost': 11.9, 'unit': 'Gigabyte-hour(s)', 'quantity': 23808.0}, # noqa
{'rate': 0.0005, 'resource_name': 'postgresql - root disk', 'cost': 3.72, 'unit': 'Gigabyte-hour(s)', 'quantity': 7440.0}, {'rate': 0.0005, 'resource_name': 'dbserver_dbvol', 'cost': 7.44, 'unit': 'Gigabyte-hour(s)', 'quantity': 14880.0}, # noqa
{'rate': 0.0005, 'resource_name': 'server_dockervol', 'cost': 18.6, 'unit': 'Gigabyte-hour(s)', 'quantity': 37200.0}, {'rate': 0.0005, 'resource_name': 'docker_uservol', 'cost': 18.6, 'unit': 'Gigabyte-hour(s)', 'quantity': 37200.0}, # noqa
{'rate': 0.0005, 'resource_name': 'docker_swap', 'cost': 11.9, 'unit': 'Gigabyte-hour(s)', 'quantity': 23808.0}]}}, # noqa
}
},
"2017-04-30": {"total_cost": 0, "details": {}},
"2017-03-31": {"total_cost": 0, "details": {}},
"2017-02-28": {"total_cost": 0, "details": {}},
"2017-01-31": {"total_cost": 0, "details": {}},
"2016-12-31": {"total_cost": 0, "details": {}},
"2016-11-30": {"total_cost": 0, "details": {}},
"2016-10-31": {"total_cost": 0, "details": {}},
"2016-09-30": {"total_cost": 0, "details": {}},
"2016-08-31": {
"total_cost": 617.0,
"status": "open",
"details": {'Network': {'total_cost': 9.64,
'breakdown': {'NZ-POR-1.n1.ipv4': [{'rate': 0.006, 'resource_name': '150.242.40.138', 'cost': 4.46, 'unit': 'Hour(s)', 'quantity': 744.0}, {'rate': 0.006, 'resource_name': '150.242.40.139', 'cost': 4.46, 'unit': 'Hour(s)', 'quantity': 744.0}]}} # noqa
}
}
},
"project_id": "093551df28e545eba9ba676dbd56bfa7",
"project_name": "default_project",
})
self.credits = mock.MagicMock()
self.credits.list = mock.Mock(return_value={'credits': [{'code': 'abcdefg', 'type': 'Cloud Trial Credit', 'expiry_date': '2017-09-30', 'balance': 300.0, 'recurring': False, 'start_date': '2017-08-02 22:16:28'}]}) # noqa
@mock.patch('distil_ui.api.distil_v2.distilclient', FakeDistilClient)
class V2BillingTests(test.TestCase):
"""Ensure the V2 api changes work. """
def setUp(self):
super(V2BillingTests, self).setUp()
region_list[:] = []
region_list.append(regionOne)
region_list.append(regionTwo)
@mock.patch("openstack_dashboard.api.base.url_for")
def test_init_distilclient(self, mock_url_for):
request = FakeRequest()
distilclient = distil_v2.distilclient(request)
self.assertIsNotNone(distilclient)
def test_calculate_start_date(self):
today = datetime.date(2017, 1, 1)
start = distil_v2._calculate_start_date(today)
self.assertEqual((start.year, start.month, start.day), (2016, 2, 1))
today = datetime.date(2017, 7, 1)
start = distil_v2._calculate_start_date(today)
self.assertEqual((start.year, start.month, start.day), (2016, 8, 1))
today = datetime.date(2017, 12, 31)
start = distil_v2._calculate_start_date(today)
self.assertEqual((start.year, start.month, start.day), (2017, 1, 1))
def test_calculate_end_date(self):
start = datetime.date(2015, 1, 1)
end = distil_v2._calculate_end_date(start)
self.assertEqual((end.year, end.month, end.day), (2015, 2, 1))
start = datetime.date(2015, 6, 6)
end = distil_v2._calculate_end_date(start)
self.assertEqual((end.year, end.month, end.day), (2015, 7, 1))
start = datetime.date(2015, 12, 31)
end = distil_v2._calculate_end_date(start)
self.assertEqual((end.year, end.month, end.day), (2016, 1, 1))
@freeze_time("2017-07-10")
@mock.patch('openstack_dashboard.api.keystone.keystoneclient',
get_fake_keystoneclient)
def test_get_cost(self):
cost = distil_v2.get_cost(FakeRequest())
self.assertEqual(cost[11]["total_cost"], 60.5)
self.assertEqual(cost[10]["total_cost"], 689.0)
self.assertEqual(cost[9]["total_cost"], 653.0)
self.assertEqual(cost[8]["total_cost"], 0)
self.assertEqual(cost[0]["total_cost"], 617)
def test_get_credit(self):
credits = distil_v2.get_credits(mock.MagicMock())
expect = {'credits': [{'code': 'abcdefg',
'type': 'Cloud Trial Credit',
'expiry_date': '2017-09-30',
'balance': 300.0,
'recurring': False,
'start_date': '2017-08-02 22:16:28'}]}
self.assertDictEqual(credits, expect)

View File

@ -27,4 +27,4 @@ testtools>=1.4.0 # MIT
xvfbwrapper>=0.1.3 #license: MIT
# Include horizon as test requirement
http://tarballs.openstack.org/horizon/horizon-master.tar.gz#egg=horizon
freezegun>=0.3.6 # Apache-2.0

View File

@ -49,6 +49,13 @@ commands =
pip install git+https://github.com/openstack/python-distilclient.git
python manage.py test {posargs}
[testenv:py27debug]
basepython = python2.7
whitelist_externals = oslo_debug_helper
commands =
pip install git+https://github.com/openstack/python-distilclient.git
oslo_debug_helper -t python manage.py test {posargs}
[testenv:eslint]
whitelist_externals = npm
commands =