diff --git a/distil_ui/api/distil.py b/distil_ui/api/distil.py deleted file mode 100644 index 5c7d254..0000000 --- a/distil_ui/api/distil.py +++ /dev/null @@ -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 diff --git a/distil_ui/api/distil_v2.py b/distil_ui/api/distil_v2.py new file mode 100644 index 0000000..bb5c65b --- /dev/null +++ b/distil_ui/api/distil_v2.py @@ -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() diff --git a/distil_ui/content/billing/base.py b/distil_ui/content/billing/base.py deleted file mode 100644 index 0ff201b..0000000 --- a/distil_ui/content/billing/base.py +++ /dev/null @@ -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']) diff --git a/distil_ui/content/billing/templates/billing/index.html b/distil_ui/content/billing/templates/billing/index.html index 8dea152..591aab5 100644 --- a/distil_ui/content/billing/templates/billing/index.html +++ b/distil_ui/content/billing/templates/billing/index.html @@ -18,103 +18,116 @@ {% block main %} -

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.

+

Disclaimer: This is an estimate for your usage cross ALL regions, not your final invoice. It includes the free router and network discount. All costs are in New Zealand dollars and are exclusive of GST.

-
-

{% trans "Month to date" %}

-
- +
+

{% trans "Credits" %}

+
    +
+
+
+

{% trans "Usage Cost History" %} +

+
+
- - {{table.render}}
-
-

{% trans "Usage cost history" %}

- +
+ + + + + + + + + + + + + + + + + + + + + + -
- -
-
Product NameResource Name/IDQuantityUnitRateCost
Product NameResource Name/IDQuantityUnitRateCost
- - - - - - - - - - - - - - - -
-

Usage Details

-
Resource ID
Resource Name
Volume
Unit
Rate
Cost
+ + +
+ diff --git a/distil_ui/content/billing/tests.py b/distil_ui/content/billing/tests.py index e845501..d489fa2 100644 --- a/distil_ui/content/billing/tests.py +++ b/distil_ui/content/billing/tests.py @@ -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) diff --git a/distil_ui/content/billing/views.py b/distil_ui/content/billing/views.py index 90d933a..e4040e7 100644 --- a/distil_ui/content/billing/views.py +++ b/distil_ui/content/billing/views.py @@ -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]]) diff --git a/distil_ui/enabled/_6100_management_billing_group.py b/distil_ui/enabled/_6100_management_billing_group.py new file mode 100644 index 0000000..2d666af --- /dev/null +++ b/distil_ui/enabled/_6100_management_billing_group.py @@ -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' diff --git a/distil_ui/enabled/_6010_management_billing.py b/distil_ui/enabled/_6110_management_billing.py similarity index 96% rename from distil_ui/enabled/_6010_management_billing.py rename to distil_ui/enabled/_6110_management_billing.py index f817ab9..3388851 100644 --- a/distil_ui/enabled/_6010_management_billing.py +++ b/distil_ui/enabled/_6110_management_billing.py @@ -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') diff --git a/distil_ui/static/catalystdashboard/jquery.simplePagination.js b/distil_ui/static/catalystdashboard/jquery.simplePagination.js new file mode 100644 index 0000000..9b48318 --- /dev/null +++ b/distil_ui/static/catalystdashboard/jquery.simplePagination.js @@ -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)); diff --git a/distil_ui/static/catalystdashboard/scss/style.css b/distil_ui/static/catalystdashboard/scss/style.css index 0d63929..353331e 100644 --- a/distil_ui/static/catalystdashboard/scss/style.css +++ b/distil_ui/static/catalystdashboard/scss/style.css @@ -14,13 +14,12 @@ } .pie { - width:100%; - height:360px + height:500px; } .line { width:100%; - height:360px; + height:200px; } #billing .total { diff --git a/distil_ui/test/api_tests/rest_api_tests.py b/distil_ui/test/api_tests/rest_api_tests.py deleted file mode 100644 index 1924385..0000000 --- a/distil_ui/test/api_tests/rest_api_tests.py +++ /dev/null @@ -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) diff --git a/distil_ui/test/api_tests/v2_api_tests.py b/distil_ui/test/api_tests/v2_api_tests.py new file mode 100644 index 0000000..30f54ed --- /dev/null +++ b/distil_ui/test/api_tests/v2_api_tests.py @@ -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) diff --git a/test-requirements.txt b/test-requirements.txt index b6b2727..58a340d 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -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 diff --git a/tox.ini b/tox.ini index 0c9d79c..6b1063f 100644 --- a/tox.ini +++ b/tox.ini @@ -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 =